| 1 | #nullable enable |
| 2 | using CascadeIDE.Models.AgentChat; |
| 3 | using CascadeIDE.Models.Intercom; |
| 4 | using CascadeIDE.Services; |
| 5 | |
| 6 | namespace CascadeIDE.Features.Chat; |
| 7 | |
| 8 | public partial class ChatPanelViewModel |
| 9 | { |
| 10 | private readonly List<ChatHistoryEvent> _sessionEventsCache = []; |
| 11 | |
| 12 | private ChatSedmScopeStrip _sedmScopeStrip = ChatSedmScopeStrip.Empty; |
| 13 | |
| 14 | public ChatSedmScopeStrip SedmScopeStrip => _sedmScopeStrip; |
| 15 | |
| 16 | private IReadOnlyList<ChatSedmTimelineEntry> BuildSedmTimelineEntries() => |
| 17 | SedmTimelineBuilder.Build(_sessionEventsCache, ResolveSedmWorklineId()); |
| 18 | |
| 19 | public string GetSedmScopeJson() |
| 20 | { |
| 21 | var strip = _sedmScopeStrip; |
| 22 | return System.Text.Json.JsonSerializer.Serialize(new |
| 23 | { |
| 24 | strip_text = strip.FormatStripText(), |
| 25 | context = strip.ContextOneLiner, |
| 26 | intent = strip.IntentOneLiner, |
| 27 | decision = strip.DecisionOneLiner, |
| 28 | decision_status = strip.DecisionStatus, |
| 29 | open_worklines = strip.OpenWorklineCount, |
| 30 | intent_incomplete = strip.IntentIncomplete, |
| 31 | }, ChatHistoryJson.Options); |
| 32 | } |
| 33 | |
| 34 | public async Task<string> RecordIntentCardFromMcpArgsAsync( |
| 35 | IReadOnlyDictionary<string, System.Text.Json.JsonElement>? args, |
| 36 | CancellationToken ct = default) |
| 37 | { |
| 38 | var card = new SedmIntentCardBodyPayload( |
| 39 | McpCommandJsonArgs.String(args, "outcome") ?? "", |
| 40 | McpCommandJsonArgs.String(args, "trigger"), |
| 41 | McpCommandJsonArgs.String(args, "chosen_approach"), |
| 42 | McpCommandJsonArgs.String(args, "selection_rationale"), |
| 43 | McpCommandJsonArgs.String(args, "constraints"), |
| 44 | McpCommandJsonArgs.String(args, "validation_plan")); |
| 45 | var considered = ParseConsideredArgs(args); |
| 46 | return await RecordIntentCardFromMcpAsync(card, considered, ct).ConfigureAwait(false); |
| 47 | } |
| 48 | |
| 49 | public async Task<string> RecordDecisionFromMcpArgsAsync( |
| 50 | IReadOnlyDictionary<string, System.Text.Json.JsonElement>? args, |
| 51 | CancellationToken ct = default) |
| 52 | { |
| 53 | var card = new SedmIntentCardBodyPayload( |
| 54 | McpCommandJsonArgs.String(args, "outcome") ?? "", |
| 55 | McpCommandJsonArgs.String(args, "trigger"), |
| 56 | McpCommandJsonArgs.String(args, "chosen_approach"), |
| 57 | McpCommandJsonArgs.String(args, "selection_rationale"), |
| 58 | McpCommandJsonArgs.String(args, "constraints"), |
| 59 | McpCommandJsonArgs.String(args, "validation_plan")); |
| 60 | var considered = ParseConsideredArgs(args); |
| 61 | var findings = ParseFindingsArgs(args); |
| 62 | var touched = McpCommandJsonArgs.StringList(args, "touched_paths"); |
| 63 | var basis = touched is { Count: > 0 } || !string.IsNullOrWhiteSpace(McpCommandJsonArgs.String(args, "revision")) |
| 64 | ? new SedmDecisionBasisPayload(McpCommandJsonArgs.String(args, "revision"), touched) |
| 65 | : null; |
| 66 | return await RecordDecisionFromMcpAsync(card, considered, findings, basis, ct).ConfigureAwait(false); |
| 67 | } |
| 68 | |
| 69 | private static IReadOnlyList<SedmIntentConsideredOptionPayload>? ParseConsideredArgs( |
| 70 | IReadOnlyDictionary<string, System.Text.Json.JsonElement>? args) |
| 71 | { |
| 72 | if (args is null || !args.TryGetValue("considered", out var el) || el.ValueKind != System.Text.Json.JsonValueKind.Array) |
| 73 | return null; |
| 74 | |
| 75 | var list = new List<SedmIntentConsideredOptionPayload>(); |
| 76 | foreach (var item in el.EnumerateArray()) |
| 77 | { |
| 78 | if (item.ValueKind != System.Text.Json.JsonValueKind.Object) |
| 79 | continue; |
| 80 | var approach = item.TryGetProperty("approach", out var a) ? a.GetString() : null; |
| 81 | if (string.IsNullOrWhiteSpace(approach)) |
| 82 | continue; |
| 83 | var rejected = item.TryGetProperty("rejected_because", out var r) ? r.GetString() : null; |
| 84 | list.Add(new SedmIntentConsideredOptionPayload(approach, rejected)); |
| 85 | } |
| 86 | |
| 87 | return list.Count == 0 ? null : list; |
| 88 | } |
| 89 | |
| 90 | private static IReadOnlyList<SedmDecisionFindingPayload>? ParseFindingsArgs( |
| 91 | IReadOnlyDictionary<string, System.Text.Json.JsonElement>? args) |
| 92 | { |
| 93 | if (args is null || !args.TryGetValue("findings", out var el) || el.ValueKind != System.Text.Json.JsonValueKind.Array) |
| 94 | return null; |
| 95 | |
| 96 | var list = new List<SedmDecisionFindingPayload>(); |
| 97 | foreach (var item in el.EnumerateArray()) |
| 98 | { |
| 99 | if (item.ValueKind != System.Text.Json.JsonValueKind.Object) |
| 100 | continue; |
| 101 | var kind = item.TryGetProperty("kind", out var k) ? k.GetString() : "note"; |
| 102 | var reference = item.TryGetProperty("ref", out var r) ? r.GetString() : ""; |
| 103 | var summary = item.TryGetProperty("summary", out var s) ? s.GetString() : ""; |
| 104 | if (string.IsNullOrWhiteSpace(summary)) |
| 105 | continue; |
| 106 | list.Add(new SedmDecisionFindingPayload(kind ?? "note", reference ?? "", summary)); |
| 107 | } |
| 108 | |
| 109 | return list.Count == 0 ? null : list; |
| 110 | } |
| 111 | |
| 112 | private Guid ResolveSedmWorklineId() |
| 113 | { |
| 114 | if (SelectedChatThreadId != Guid.Empty) |
| 115 | return SelectedChatThreadId; |
| 116 | if (_activeThreadId != Guid.Empty) |
| 117 | return _activeThreadId; |
| 118 | return _mainThreadId; |
| 119 | } |
| 120 | |
| 121 | private int ResolveOpenWorklineCount() |
| 122 | { |
| 123 | var ids = new HashSet<Guid>(); |
| 124 | if (_mainThreadId != Guid.Empty) |
| 125 | ids.Add(_mainThreadId); |
| 126 | foreach (var msg in ChatMessages) |
| 127 | { |
| 128 | if (msg.ThreadId != Guid.Empty) |
| 129 | ids.Add(msg.ThreadId); |
| 130 | } |
| 131 | foreach (var fork in _threadForks) |
| 132 | { |
| 133 | if (fork.NewThreadId != Guid.Empty) |
| 134 | ids.Add(fork.NewThreadId); |
| 135 | } |
| 136 | return Math.Max(1, ids.Count); |
| 137 | } |
| 138 | |
| 139 | private void RebuildSedmScopeStrip() |
| 140 | { |
| 141 | var worklineId = ResolveSedmWorklineId(); |
| 142 | var openCount = ResolveOpenWorklineCount(); |
| 143 | var projection = SedmEventProjector.Project( |
| 144 | _sessionEventsCache, |
| 145 | worklineId, |
| 146 | _threadDisplayTitles, |
| 147 | openCount); |
| 148 | var workline = SedmEventProjector.ResolveWorkline(projection, worklineId); |
| 149 | _sedmScopeStrip = ChatSedmScopeStrip.FromProjection(workline, openCount); |
| 150 | } |
| 151 | |
| 152 | private void ReplaceSessionEventsCache(IReadOnlyList<ChatHistoryEvent> events) |
| 153 | { |
| 154 | _sessionEventsCache.Clear(); |
| 155 | _sessionEventsCache.AddRange(events); |
| 156 | RebuildSedmScopeStrip(); |
| 157 | } |
| 158 | |
| 159 | private void AppendSessionEventCache(ChatHistoryEvent ev) |
| 160 | { |
| 161 | _sessionEventsCache.Add(ev); |
| 162 | RebuildSedmScopeStrip(); |
| 163 | } |
| 164 | |
| 165 | private string ApplySedmMaterializationToOutboundMessage(string input) |
| 166 | { |
| 167 | var prefix = _sedmScopeStrip.BuildAgentContextPrefix(); |
| 168 | if (string.IsNullOrWhiteSpace(prefix)) |
| 169 | return input; |
| 170 | return prefix + Environment.NewLine + input; |
| 171 | } |
| 172 | |
| 173 | private string ApplyAgentContextPrefixes(string input) |
| 174 | { |
| 175 | var withSedm = ApplySedmMaterializationToOutboundMessage(input); |
| 176 | return ApplyProductSpineToOutboundMessage(withSedm); |
| 177 | } |
| 178 | |
| 179 | /// <summary>MCP/агент: записать intent card (T1) в event log.</summary> |
| 180 | public async Task<string> RecordIntentCardFromMcpAsync( |
| 181 | SedmIntentCardBodyPayload card, |
| 182 | IReadOnlyList<SedmIntentConsideredOptionPayload>? considered, |
| 183 | CancellationToken ct = default) |
| 184 | { |
| 185 | var worklineId = ResolveSedmWorklineId(); |
| 186 | if (worklineId == Guid.Empty) |
| 187 | return "no_active_workline"; |
| 188 | |
| 189 | var payload = ChatHistoryPayloadMapping.ToIntentCardRecordedPayload(worklineId, card, considered); |
| 190 | await PersistSedmEventAsync(ChatHistoryEventKind.IntentCardRecorded, payload, worklineId, ct) |
| 191 | .ConfigureAwait(false); |
| 192 | RefreshChatSurfaceSnapshot(); |
| 193 | return "OK"; |
| 194 | } |
| 195 | |
| 196 | /// <summary>MCP/агент: записать decision_recorded (unified model §12).</summary> |
| 197 | public async Task<string> RecordDecisionFromMcpAsync( |
| 198 | SedmIntentCardBodyPayload card, |
| 199 | IReadOnlyList<SedmIntentConsideredOptionPayload>? considered, |
| 200 | IReadOnlyList<SedmDecisionFindingPayload>? findings, |
| 201 | SedmDecisionBasisPayload? basis, |
| 202 | CancellationToken ct = default) |
| 203 | { |
| 204 | var worklineId = ResolveSedmWorklineId(); |
| 205 | if (worklineId == Guid.Empty) |
| 206 | return "no_active_workline"; |
| 207 | |
| 208 | var payload = ChatHistoryPayloadMapping.ToDecisionRecordedPayload( |
| 209 | worklineId, |
| 210 | card, |
| 211 | considered, |
| 212 | findings, |
| 213 | basis); |
| 214 | await PersistSedmEventAsync(ChatHistoryEventKind.DecisionRecorded, payload, worklineId, ct) |
| 215 | .ConfigureAwait(false); |
| 216 | await TryMarkCrossWorklineDecisionsStaleAsync(basis, worklineId, ct).ConfigureAwait(false); |
| 217 | RefreshChatSurfaceSnapshot(); |
| 218 | return "OK"; |
| 219 | } |
| 220 | |
| 221 | public string GetSedmScopeStripText() => _sedmScopeStrip.FormatStripText(); |
| 222 | |
| 223 | private async Task PersistSedmEventAsync<T>( |
| 224 | string kind, |
| 225 | T payload, |
| 226 | Guid worklineId, |
| 227 | CancellationToken ct) |
| 228 | { |
| 229 | await PersistEventAsync(kind, payload, worklineId).ConfigureAwait(false); |
| 230 | } |
| 231 | |
| 232 | private async Task MaybeMaterializeContextCardAsync( |
| 233 | IReadOnlyList<AttachmentAnchor> attachments, |
| 234 | string triggerReason, |
| 235 | CancellationToken ct = default) |
| 236 | { |
| 237 | if (attachments.Count == 0) |
| 238 | return; |
| 239 | |
| 240 | var anchor = attachments[0]; |
| 241 | var path = ResolveAnchorPath(anchor); |
| 242 | if (string.IsNullOrWhiteSpace(path)) |
| 243 | return; |
| 244 | |
| 245 | var worklineId = ResolveSedmWorklineId(); |
| 246 | if (worklineId == Guid.Empty) |
| 247 | return; |
| 248 | |
| 249 | var workline = SedmEventProjector.ResolveWorkline( |
| 250 | SedmEventProjector.Project(_sessionEventsCache, worklineId, _threadDisplayTitles), |
| 251 | worklineId); |
| 252 | |
| 253 | var label = _threadDisplayTitles.TryGetValue(worklineId, out var title) ? title : null; |
| 254 | var workspace = ResolveAttachWorkspaceRoot(); |
| 255 | var applies = SedmAppliesResolver.Resolve(workspace, path); |
| 256 | var pathHint = SedmAppliesResolver.BuildPathHint(applies, path); |
| 257 | var payload = ChatHistoryPayloadMapping.ToContextCardMaterializedPayload( |
| 258 | worklineId, |
| 259 | path, |
| 260 | ResolveAnchorSymbol(anchor), |
| 261 | label, |
| 262 | pathHint: pathHint, |
| 263 | triggerReason: triggerReason, |
| 264 | applies: applies); |
| 265 | |
| 266 | if (SedmEventProjector.IsSameContextCard(workline.ContextCard, payload)) |
| 267 | return; |
| 268 | |
| 269 | await PersistSedmEventAsync( |
| 270 | ChatHistoryEventKind.ContextCardMaterialized, |
| 271 | payload, |
| 272 | worklineId, |
| 273 | ct).ConfigureAwait(false); |
| 274 | } |
| 275 | |
| 276 | private async Task MaybeMaterializeContextCardOnWorklineSwitchAsync(CancellationToken ct = default) |
| 277 | { |
| 278 | var worklineId = ResolveSedmWorklineId(); |
| 279 | if (worklineId == Guid.Empty) |
| 280 | return; |
| 281 | |
| 282 | var projection = SedmEventProjector.Project(_sessionEventsCache, worklineId, _threadDisplayTitles); |
| 283 | var workline = SedmEventProjector.ResolveWorkline(projection, worklineId); |
| 284 | if (workline.ContextCard is not null) |
| 285 | return; |
| 286 | |
| 287 | SedmContextCardMaterializedPayload? source = null; |
| 288 | foreach (var wl in projection.ByWorkline.Values) |
| 289 | { |
| 290 | if (wl.ContextCard is not null) |
| 291 | source = wl.ContextCard; |
| 292 | } |
| 293 | |
| 294 | if (source is null) |
| 295 | return; |
| 296 | |
| 297 | var label = _threadDisplayTitles.TryGetValue(worklineId, out var title) ? title : null; |
| 298 | var applies = source.Applies?.Count > 0 |
| 299 | ? source.Applies |
| 300 | : SedmAppliesResolver.Resolve(ResolveAttachWorkspaceRoot(), source.Anchor.Path); |
| 301 | var pathHint = source.PathHint ?? SedmAppliesResolver.BuildPathHint(applies, source.Anchor.Path); |
| 302 | var payload = ChatHistoryPayloadMapping.ToContextCardMaterializedPayload( |
| 303 | worklineId, |
| 304 | source.Anchor.Path, |
| 305 | source.Anchor.Symbol, |
| 306 | label, |
| 307 | pathHint: pathHint, |
| 308 | triggerReason: "workline_switch", |
| 309 | applies: applies); |
| 310 | |
| 311 | await PersistSedmEventAsync(ChatHistoryEventKind.ContextCardMaterialized, payload, worklineId, ct) |
| 312 | .ConfigureAwait(false); |
| 313 | } |
| 314 | |
| 315 | private static string? ResolveAnchorPath(AttachmentAnchor anchor) |
| 316 | { |
| 317 | if (!string.IsNullOrWhiteSpace(anchor.File)) |
| 318 | return anchor.File.Replace('\\', '/').Trim(); |
| 319 | return null; |
| 320 | } |
| 321 | |
| 322 | private static string? ResolveAnchorSymbol(AttachmentAnchor anchor) => |
| 323 | string.IsNullOrWhiteSpace(anchor.MemberKey) ? null : anchor.MemberKey.Trim(); |
| 324 | |
| 325 | private async Task TryMarkCrossWorklineDecisionsStaleAsync( |
| 326 | SedmDecisionBasisPayload? basis, |
| 327 | Guid activeWorklineId, |
| 328 | CancellationToken ct) |
| 329 | { |
| 330 | if (basis?.TouchedPaths is not { Count: > 0 }) |
| 331 | return; |
| 332 | |
| 333 | var touched = new HashSet<string>( |
| 334 | basis.TouchedPaths.Select(static p => p.Replace('\\', '/').Trim()), |
| 335 | StringComparer.OrdinalIgnoreCase); |
| 336 | var activeKey = activeWorklineId.ToString("N"); |
| 337 | |
| 338 | foreach (var ev in _sessionEventsCache) |
| 339 | { |
| 340 | if (!string.Equals(ev.Kind, ChatHistoryEventKind.DecisionRecorded, StringComparison.Ordinal)) |
| 341 | continue; |
| 342 | |
| 343 | SedmDecisionRecordedPayload? decision; |
| 344 | try |
| 345 | { |
| 346 | decision = System.Text.Json.JsonSerializer.Deserialize<SedmDecisionRecordedPayload>( |
| 347 | ev.PayloadJson, |
| 348 | ChatHistoryJson.Options); |
| 349 | } |
| 350 | catch (System.Text.Json.JsonException) |
| 351 | { |
| 352 | continue; |
| 353 | } |
| 354 | |
| 355 | if (decision is null |
| 356 | || string.Equals(decision.WorklineId, activeKey, StringComparison.OrdinalIgnoreCase) |
| 357 | || decision.Basis?.TouchedPaths is not { Count: > 0 } paths) |
| 358 | continue; |
| 359 | |
| 360 | if (!paths.Any(p => touched.Contains(p.Replace('\\', '/').Trim()))) |
| 361 | continue; |
| 362 | |
| 363 | var decisionKey = ev.EventId.ToString("N"); |
| 364 | if (_sessionEventsCache.Any(e => |
| 365 | string.Equals(e.Kind, ChatHistoryEventKind.DecisionMarkedStale, StringComparison.Ordinal) |
| 366 | && e.PayloadJson.Contains(decisionKey, StringComparison.OrdinalIgnoreCase))) |
| 367 | continue; |
| 368 | |
| 369 | if (!Guid.TryParse(decision.WorklineId, out var staleWorklineId)) |
| 370 | continue; |
| 371 | |
| 372 | var stalePayload = ChatHistoryPayloadMapping.ToDecisionLifecyclePayload( |
| 373 | staleWorklineId, |
| 374 | ev.EventId, |
| 375 | "cross_workline_path_touch"); |
| 376 | await PersistSedmEventAsync( |
| 377 | ChatHistoryEventKind.DecisionMarkedStale, |
| 378 | stalePayload, |
| 379 | staleWorklineId, |
| 380 | ct).ConfigureAwait(false); |
| 381 | } |
| 382 | } |
| 383 | } |
| 384 | |