Forge
csharpdeeb25a2
1#nullable enable
2using CascadeIDE.Models.AgentChat;
3
4namespace CascadeIDE.Features.Chat;
5
6/// <summary>Системная карточка SEDM в ленте workline (ADR 0173 P1).</summary>
7public sealed record ChatSedmTimelineEntry(
8 Guid EventId,
9 Guid WorklineId,
10 string Kind,
11 string Title,
12 string Body,
13 ChatMessageVisualRole VisualRole,
14 DateTimeOffset AtUtc,
15 int Sequence);
16
17/// <summary>Проекция SEDM-событий → timeline entries для активной workline.</summary>
18internal static class SedmTimelineBuilder
19{
20 public static IReadOnlyList<ChatSedmTimelineEntry> Build(
21 IReadOnlyList<ChatHistoryEvent> events,
22 Guid worklineId)
23 {
24 if (worklineId == Guid.Empty || events.Count == 0)
25 return [];
26
27 var key = worklineId.ToString("N");
28 var result = new List<ChatSedmTimelineEntry>();
29 var seq = 0;
30
31 foreach (var ev in events.OrderBy(static e => e.AtUtc).ThenBy(static e => e.EventId))
32 {
33 if (!BelongsToWorkline(ev, key))
34 continue;
35
36 if (TryMap(ev, worklineId, ref seq, out var entry))
37 result.Add(entry);
38 }
39
40 return result;
41 }
42
43 private static bool BelongsToWorkline(ChatHistoryEvent ev, string worklineKey)
44 {
45 if (ev.ThreadId is { } tid && string.Equals(tid, worklineKey, StringComparison.OrdinalIgnoreCase))
46 return true;
47
48 return ev.PayloadJson.Contains($"\"workline_id\":\"{worklineKey}\"", StringComparison.OrdinalIgnoreCase)
49 || ev.PayloadJson.Contains($"\"workline_id\": \"{worklineKey}\"", StringComparison.OrdinalIgnoreCase);
50 }
51
52 private static bool TryMap(
53 ChatHistoryEvent ev,
54 Guid worklineId,
55 ref int seq,
56 out ChatSedmTimelineEntry entry)
57 {
58 entry = default!;
59 try
60 {
61 switch (ev.Kind)
62 {
63 case ChatHistoryEventKind.ContextCardMaterialized:
64 var ctx = Deserialize<SedmContextCardMaterializedPayload>(ev.PayloadJson);
65 if (ctx is null)
66 return false;
67 entry = new ChatSedmTimelineEntry(
68 ev.EventId,
69 worklineId,
70 ev.Kind,
71 "Context",
72 FormatContextBody(ctx),
73 ChatMessageVisualRole.SedmContext,
74 ev.AtUtc,
75 seq++);
76 return true;
77
78 case ChatHistoryEventKind.IntentCardRecorded:
79 var intent = Deserialize<SedmIntentCardRecordedPayload>(ev.PayloadJson);
80 if (intent is null)
81 return false;
82 entry = new ChatSedmTimelineEntry(
83 ev.EventId,
84 worklineId,
85 ev.Kind,
86 "Intent",
87 FormatIntentBody(intent),
88 ChatMessageVisualRole.SedmIntent,
89 ev.AtUtc,
90 seq++);
91 return true;
92
93 case ChatHistoryEventKind.DecisionRecorded:
94 var decision = Deserialize<SedmDecisionRecordedPayload>(ev.PayloadJson);
95 if (decision is null)
96 return false;
97 entry = new ChatSedmTimelineEntry(
98 ev.EventId,
99 worklineId,
100 ev.Kind,
101 "Decision",
102 FormatDecisionBody(decision),
103 ChatMessageVisualRole.SedmDecision,
104 ev.AtUtc,
105 seq++);
106 return true;
107
108 case ChatHistoryEventKind.DecisionMarkedStale:
109 case ChatHistoryEventKind.DecisionSuperseded:
110 var life = Deserialize<SedmDecisionLifecyclePayload>(ev.PayloadJson);
111 if (life is null)
112 return false;
113 var label = ev.Kind == ChatHistoryEventKind.DecisionMarkedStale ? "Decision stale" : "Decision superseded";
114 entry = new ChatSedmTimelineEntry(
115 ev.EventId,
116 worklineId,
117 ev.Kind,
118 label,
119 string.IsNullOrWhiteSpace(life.Reason) ? life.DecisionEventId : life.Reason.Trim(),
120 ChatMessageVisualRole.SedmLifecycle,
121 ev.AtUtc,
122 seq++);
123 return true;
124
125 default:
126 return false;
127 }
128 }
129 catch
130 {
131 return false;
132 }
133 }
134
135 private static string FormatContextBody(SedmContextCardMaterializedPayload ctx)
136 {
137 var path = ctx.Anchor.Path;
138 var symbol = string.IsNullOrWhiteSpace(ctx.Anchor.Symbol) ? "" : $" :: {ctx.Anchor.Symbol}";
139 var applies = ctx.Applies?.FirstOrDefault();
140 var appliesLine = applies is null ? "" : $"{Environment.NewLine}Applies: ADR {applies.Ref} — {applies.OneLiner}";
141 var hint = string.IsNullOrWhiteSpace(ctx.PathHint) ? "" : $"{Environment.NewLine}Path: {ctx.PathHint}";
142 return $"Here: {path}{symbol}{appliesLine}{hint}".Trim();
143 }
144
145 private static string FormatIntentBody(SedmIntentCardRecordedPayload intent)
146 {
147 var lines = new List<string>();
148 if (!string.IsNullOrWhiteSpace(intent.Card.Trigger))
149 lines.Add("Trigger: " + intent.Card.Trigger.Trim());
150 if (!string.IsNullOrWhiteSpace(intent.Card.Outcome))
151 lines.Add("Outcome: " + intent.Card.Outcome.Trim());
152 if (!string.IsNullOrWhiteSpace(intent.Card.ChosenApproach))
153 lines.Add("Chosen: " + intent.Card.ChosenApproach.Trim());
154 if (!string.IsNullOrWhiteSpace(intent.Card.SelectionRationale))
155 lines.Add("Because: " + intent.Card.SelectionRationale.Trim());
156 if (intent.Considered is { Count: > 0 })
157 {
158 lines.Add("Rejected:");
159 foreach (var c in intent.Considered.Take(3))
160 lines.Add($" · {c.Approach}: {c.RejectedBecause}");
161 }
162 if (SedmCardCompleteness.IsIntentIncomplete(intent))
163 lines.Add("(incomplete — add considered[] or rationale)");
164 return lines.Count == 0 ? "Intent card" : string.Join(Environment.NewLine, lines);
165 }
166
167 private static string FormatDecisionBody(SedmDecisionRecordedPayload decision)
168 {
169 var lines = new List<string>();
170 if (!string.IsNullOrWhiteSpace(decision.Card.Outcome))
171 lines.Add("Outcome: " + decision.Card.Outcome.Trim());
172 if (!string.IsNullOrWhiteSpace(decision.Card.ChosenApproach))
173 lines.Add("Chosen: " + decision.Card.ChosenApproach.Trim());
174 if (decision.Findings is { Count: > 0 })
175 {
176 lines.Add("Findings:");
177 foreach (var f in decision.Findings.Take(4))
178 lines.Add($" · [{f.Kind}] {f.Summary}");
179 }
180 if (!string.Equals(decision.Status, "active", StringComparison.OrdinalIgnoreCase))
181 lines.Add($"Status: {decision.Status}");
182 return lines.Count == 0 ? "Decision record" : string.Join(Environment.NewLine, lines);
183 }
184
185 private static T? Deserialize<T>(string json) =>
186 System.Text.Json.JsonSerializer.Deserialize<T>(json, ChatHistoryJson.Options);
187}
188
View only · write via MCP/CIDE