Forge
csharpdeeb25a2
1#nullable enable
2using CascadeIDE.Models.AgentChat;
3using CascadeIDE.Models.Intercom;
4using CascadeIDE.Services;
5
6namespace CascadeIDE.Features.Chat;
7
8public 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
View only · write via MCP/CIDE