Forge
csharpdeeb25a2
1using System.Text.Json;
2using CascadeIDE.Models.AgentChat;
3using CascadeIDE.Models.Intercom;
4
5namespace CascadeIDE.Features.Chat;
6
7/// <summary>
8/// Детерминированная проекция списка сообщений из append-only событий (ADR 0045).
9/// </summary>
10internal static class ChatHistoryMessageProjector
11{
12 public sealed record Row(
13 Guid MessageId,
14 string Role,
15 string Content,
16 Guid ThreadId,
17 Guid? ParentMessageId,
18 IReadOnlyList<AttachmentAnchor> Attachments,
19 SenderWorkspaceContext? SenderWorkspaceContext,
20 string? SlashCommandPath = null,
21 string? SlashCommandArgs = null,
22 ChatSlashCommandStatus? SlashCommandStatus = null,
23 IntercomMessageAudience Audience = IntercomMessageAudience.Channel);
24
25 public static List<Row> Project(IReadOnlyList<ChatHistoryEvent> events, Guid defaultThreadId)
26 {
27 var rows = new List<Row>();
28 var indexById = new Dictionary<Guid, int>();
29
30 foreach (var ev in events)
31 {
32 if (string.Equals(ev.Kind, ChatHistoryEventKind.MessageAdded, StringComparison.Ordinal)
33 || string.Equals(ev.Kind, ChatHistoryEventKind.MessageCompleted, StringComparison.Ordinal))
34 {
35 if (!TryParseMessagePayload(ev.PayloadJson, defaultThreadId, out var row))
36 continue;
37 Upsert(rows, indexById, row);
38 }
39 else if (string.Equals(ev.Kind, ChatHistoryEventKind.MessageEdited, StringComparison.Ordinal))
40 {
41 if (!TryParseMessageEdited(ev.PayloadJson, out var messageId, out var newContent))
42 continue;
43 if (indexById.TryGetValue(messageId, out var idx))
44 rows[idx] = rows[idx] with { Content = newContent };
45 }
46 }
47
48 return rows;
49 }
50
51 /// <summary>
52 /// Восстановить корневую ветку из событий (meta без <c>main_thread_id</c> или сброс после миграции).
53 /// </summary>
54 public static Guid InferMainThreadId(IReadOnlyList<ChatHistoryEvent> events)
55 {
56 var rows = Project(events, Guid.Empty);
57 if (rows.Count == 0)
58 return Guid.NewGuid();
59
60 return rows
61 .GroupBy(row => row.ThreadId)
62 .OrderByDescending(group => group.Count())
63 .ThenBy(group => rows.FindIndex(row => row.ThreadId == group.Key))
64 .First()
65 .Key;
66 }
67
68 private static void Upsert(
69 List<Row> rows,
70 Dictionary<Guid, int> indexById,
71 Row row)
72 {
73 if (indexById.TryGetValue(row.MessageId, out var idx))
74 {
75 rows[idx] = row;
76 return;
77 }
78
79 indexById[row.MessageId] = rows.Count;
80 rows.Add(row);
81 }
82
83 private static bool TryParseMessagePayload(string payloadJson, Guid defaultThreadId, out Row row)
84 {
85 row = default!;
86 ChatHistoryMessagePayload? payload;
87 try
88 {
89 payload = JsonSerializer.Deserialize<ChatHistoryMessagePayload>(payloadJson, ChatHistoryJson.Options);
90 }
91 catch (JsonException)
92 {
93 return TryParseMessagePayloadLegacy(payloadJson, defaultThreadId, out row);
94 }
95
96 if (payload is null)
97 return TryParseMessagePayloadLegacy(payloadJson, defaultThreadId, out row);
98
99 if (!Guid.TryParse(payload.MessageId, out var messageId))
100 messageId = Guid.NewGuid();
101
102 var threadId = Guid.TryParse(payload.ThreadId, out var tid) && tid != Guid.Empty
103 ? tid
104 : defaultThreadId;
105
106 Guid? parentMessageId = null;
107 if (!string.IsNullOrWhiteSpace(payload.ParentMessageId)
108 && Guid.TryParse(payload.ParentMessageId, out var parent)
109 && parent != Guid.Empty)
110 {
111 parentMessageId = parent;
112 }
113
114 ChatSlashCommandStatus? slashStatus = null;
115 if (!string.IsNullOrWhiteSpace(payload.SlashCommandStatus)
116 && Enum.TryParse<ChatSlashCommandStatus>(payload.SlashCommandStatus, ignoreCase: true, out var parsedStatus))
117 {
118 slashStatus = parsedStatus;
119 }
120
121 var audience = payload.Audience ?? IntercomMessageAudience.Channel;
122
123 row = new Row(
124 messageId,
125 string.IsNullOrWhiteSpace(payload.Role) ? "assistant" : payload.Role,
126 payload.Content ?? "",
127 threadId,
128 parentMessageId,
129 payload.Attachments ?? [],
130 payload.SenderWorkspaceContext,
131 payload.SlashCommandPath,
132 string.IsNullOrWhiteSpace(payload.SlashCommandArgs) ? null : payload.SlashCommandArgs,
133 slashStatus,
134 audience);
135 return true;
136 }
137
138 private static bool TryParseMessageEdited(string payloadJson, out Guid messageId, out string newContent)
139 {
140 messageId = Guid.Empty;
141 newContent = "";
142
143 ChatHistoryMessageEditedPayload? payload;
144 try
145 {
146 payload = JsonSerializer.Deserialize<ChatHistoryMessageEditedPayload>(payloadJson, ChatHistoryJson.Options);
147 }
148 catch (JsonException)
149 {
150 return TryParseMessageEditedLegacy(payloadJson, out messageId, out newContent);
151 }
152
153 if (payload is null)
154 return TryParseMessageEditedLegacy(payloadJson, out messageId, out newContent);
155
156 if (!Guid.TryParse(payload.MessageId, out messageId) || messageId == Guid.Empty)
157 return false;
158
159 newContent = payload.NewContent ?? "";
160 return true;
161 }
162
163 /// <summary>Чтение старых NDJSON до typed payload (ручной разбор).</summary>
164 private static bool TryParseMessageEditedLegacy(string payloadJson, out Guid messageId, out string newContent)
165 {
166 messageId = Guid.Empty;
167 newContent = "";
168 using var doc = JsonDocument.Parse(payloadJson);
169 var root = doc.RootElement;
170
171 if (root.TryGetProperty("message_id", out var p) || root.TryGetProperty("MessageId", out p))
172 {
173 var s = p.GetString();
174 if (string.IsNullOrWhiteSpace(s) || !Guid.TryParse(s, out messageId))
175 return false;
176 }
177 else
178 {
179 return false;
180 }
181
182 if (root.TryGetProperty("new_content", out var nc))
183 newContent = nc.GetString() ?? "";
184 else if (root.TryGetProperty("NewContent", out var nc2))
185 newContent = nc2.GetString() ?? "";
186
187 return messageId != Guid.Empty;
188 }
189
190 /// <summary>NDJSON до typed payload (<c>Dictionary</c>-эра и PascalCase).</summary>
191 private static bool TryParseMessagePayloadLegacy(string payloadJson, Guid defaultThreadId, out Row row)
192 {
193 row = default!;
194 try
195 {
196 using var doc = JsonDocument.Parse(payloadJson);
197 var root = doc.RootElement;
198
199 if (!TryGetGuidProperty(root, "message_id", "MessageId", out var messageId))
200 messageId = Guid.NewGuid();
201
202 var role = TryGetStringProperty(root, "role", "Role") ?? "assistant";
203 var content = TryGetStringProperty(root, "content", "Content") ?? "";
204
205 var threadId = defaultThreadId;
206 if (TryGetGuidProperty(root, "thread_id", "ThreadId", out var tid) && tid != Guid.Empty)
207 threadId = tid;
208
209 Guid? parentMessageId = null;
210 if (TryGetGuidProperty(root, "parent_message_id", "ParentMessageId", out var parent) && parent != Guid.Empty)
211 parentMessageId = parent;
212
213 row = new Row(messageId, role, content, threadId, parentMessageId, [], null);
214 return true;
215 }
216 catch (JsonException)
217 {
218 return false;
219 }
220 }
221
222 private static bool TryGetGuidProperty(
223 JsonElement root,
224 string snakeName,
225 string pascalName,
226 out Guid value)
227 {
228 value = Guid.Empty;
229 if (!root.TryGetProperty(snakeName, out var el) && !root.TryGetProperty(pascalName, out el))
230 return false;
231
232 var s = el.GetString();
233 return !string.IsNullOrWhiteSpace(s) && Guid.TryParse(s, out value);
234 }
235
236 private static string? TryGetStringProperty(JsonElement root, string snakeName, string pascalName)
237 {
238 if (root.TryGetProperty(snakeName, out var el) || root.TryGetProperty(pascalName, out el))
239 return el.GetString();
240 return null;
241 }
242}
243
View only · write via MCP/CIDE