Skip to content

Commit 71d3dfc

Browse files
refactor(chat): store canonical thread snapshots
1 parent 03bcd2a commit 71d3dfc

4 files changed

Lines changed: 621 additions & 52 deletions

File tree

apps/chat/lib/stores/with-threads.test.ts

Lines changed: 251 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ function createMessage({
1313
parallelGroupId = null,
1414
parallelIndex = null,
1515
activeStreamId = null,
16+
text = "",
1617
}: {
1718
id: string;
1819
role: ChatMessage["role"];
@@ -21,11 +22,12 @@ function createMessage({
2122
parallelGroupId?: string | null;
2223
parallelIndex?: number | null;
2324
activeStreamId?: string | null;
25+
text?: string;
2426
}): ChatMessage {
2527
return {
2628
id,
2729
role,
28-
parts: [],
30+
parts: text ? [{ type: "text", text }] : [],
2931
metadata: {
3032
createdAt: new Date(createdAt),
3133
parentMessageId,
@@ -44,14 +46,82 @@ function createThreadStore(initialMessages: ChatMessage[]) {
4446
withThreads<ChatMessage, BaseChatStoreState<ChatMessage>>(
4547
(set) =>
4648
({
49+
_messageIndex: { update: () => undefined },
50+
_memoizedSelectors: new Map(),
51+
_throttledMessages: initialMessages,
4752
messages: initialMessages,
4853
setMessages: (messages: ChatMessage[]) => set({ messages }),
49-
}) as BaseChatStoreState<ChatMessage>
54+
}) as unknown as BaseChatStoreState<ChatMessage>
5055
)
5156
);
5257
}
5358

5459
describe("withThreads", () => {
60+
it("preserves hidden branches when ThreadChat publishes an active-path snapshot", () => {
61+
const userA = createMessage({
62+
id: "user-a",
63+
role: "user",
64+
createdAt: "2024-01-01T00:00:00.000Z",
65+
});
66+
const assistantA = createMessage({
67+
id: "assistant-a",
68+
role: "assistant",
69+
createdAt: "2024-01-01T00:00:01.000Z",
70+
parentMessageId: userA.id,
71+
});
72+
const userB = createMessage({
73+
id: "user-b",
74+
role: "user",
75+
createdAt: "2024-01-01T00:00:02.000Z",
76+
});
77+
const assistantB = createMessage({
78+
id: "assistant-b",
79+
role: "assistant",
80+
createdAt: "2024-01-01T00:00:03.000Z",
81+
parentMessageId: userB.id,
82+
});
83+
const store = createThreadStore([userB, assistantB]);
84+
85+
store.getState().setAllMessages([userA, assistantA, userB, assistantB]);
86+
store.getState().setTreeSnapshot({
87+
childrenByParentId: {
88+
__root__: [userB.id],
89+
[userB.id]: [assistantB.id],
90+
},
91+
cursorId: assistantB.id,
92+
messagesById: {
93+
[userB.id]: userB,
94+
[assistantB.id]: assistantB,
95+
},
96+
parentById: {
97+
[userB.id]: null,
98+
[assistantB.id]: userB.id,
99+
},
100+
rootIds: [userB.id],
101+
version: 1,
102+
});
103+
104+
const siblingInfo = store.getState().getMessageSiblingInfo(userB.id);
105+
assert.deepEqual(
106+
siblingInfo?.siblings.map((message) => message.id),
107+
[userA.id, userB.id]
108+
);
109+
const previousThread = store.getState().switchToSibling(userB.id, "prev");
110+
const previousThreadIds = [userA.id, assistantA.id];
111+
assert.deepEqual(
112+
previousThread?.map((message) => message.id),
113+
previousThreadIds
114+
);
115+
assert.deepEqual(
116+
store.getState().messages.map((message) => message.id),
117+
previousThreadIds
118+
);
119+
assert.deepEqual(
120+
store.getState()._throttledMessages?.map((message) => message.id),
121+
previousThreadIds
122+
);
123+
});
124+
55125
it("preserves local-only optimistic branch nodes across server syncs", () => {
56126
const rootUser = createMessage({
57127
id: "user-root",
@@ -118,9 +188,9 @@ describe("withThreads", () => {
118188
assert.deepEqual(allMessageIds, [
119189
"user-root",
120190
"assistant-a",
121-
"assistant-b",
122191
"user-nested",
123192
"assistant-nested-a",
193+
"assistant-b",
124194
"assistant-nested-b",
125195
]);
126196

@@ -134,4 +204,182 @@ describe("withThreads", () => {
134204
"pending:assistant-nested-b"
135205
);
136206
});
207+
208+
it("preserves a pending stream marker when a run inserts its assistant shell", () => {
209+
const rootUser = createMessage({
210+
id: "user-root",
211+
role: "user",
212+
createdAt: "2024-01-01T00:00:00.000Z",
213+
});
214+
const assistant = createMessage({
215+
id: "assistant-a",
216+
role: "assistant",
217+
createdAt: "2024-01-01T00:00:01.000Z",
218+
parentMessageId: rootUser.id,
219+
activeStreamId: "pending:assistant-a",
220+
});
221+
const assistantWithoutMetadata = {
222+
id: assistant.id,
223+
parts: [],
224+
role: "assistant",
225+
} as unknown as ChatMessage;
226+
227+
const store = createThreadStore([rootUser, assistant]);
228+
store.getState().addMessageToTree(assistantWithoutMetadata);
229+
230+
const updatedAssistant = store.getState().allMessages.at(-1);
231+
assert.equal(
232+
updatedAssistant?.metadata.activeStreamId,
233+
"pending:assistant-a"
234+
);
235+
assert.equal(
236+
updatedAssistant?.metadata.selectedModel,
237+
assistant.metadata.selectedModel
238+
);
239+
});
240+
241+
it("replaces a selected placeholder with completed server content when ready", () => {
242+
const rootUser = createMessage({
243+
id: "user-root",
244+
role: "user",
245+
createdAt: "2024-01-01T00:00:00.000Z",
246+
});
247+
const placeholder = createMessage({
248+
id: "assistant-b",
249+
role: "assistant",
250+
createdAt: "2024-01-01T00:00:01.000Z",
251+
parentMessageId: rootUser.id,
252+
parallelGroupId: "group-root",
253+
parallelIndex: 1,
254+
activeStreamId: "pending:assistant-b",
255+
});
256+
const completed = createMessage({
257+
id: placeholder.id,
258+
role: "assistant",
259+
createdAt: "2024-01-01T00:00:01.000Z",
260+
parentMessageId: rootUser.id,
261+
parallelGroupId: "group-root",
262+
parallelIndex: 1,
263+
text: "Completed secondary response",
264+
});
265+
const store = createThreadStore([rootUser, placeholder]);
266+
267+
store.getState().setAllMessages([rootUser, completed]);
268+
269+
const completedPart = store.getState().messages.at(-1)?.parts.at(0);
270+
assert.equal(completedPart?.type, "text");
271+
assert.equal(
272+
completedPart?.type === "text" ? completedPart.text : null,
273+
"Completed secondary response"
274+
);
275+
assert.equal(
276+
store.getState().messages.at(-1)?.metadata.activeStreamId,
277+
null
278+
);
279+
const throttledPart = store
280+
.getState()
281+
._throttledMessages?.at(-1)
282+
?.parts.at(0);
283+
assert.equal(
284+
throttledPart?.type === "text" ? throttledPart.text : null,
285+
"Completed secondary response"
286+
);
287+
});
288+
289+
it("fills metadata for ThreadChat assistant shells in tree snapshots", () => {
290+
const rootUser = createMessage({
291+
id: "user-root",
292+
role: "user",
293+
createdAt: "2024-01-01T00:00:00.000Z",
294+
});
295+
const assistantWithoutMetadata = {
296+
id: "assistant-a",
297+
parts: [],
298+
role: "assistant",
299+
} as unknown as ChatMessage;
300+
301+
const store = createThreadStore([rootUser]);
302+
303+
store.getState().setTreeSnapshot({
304+
childrenByParentId: {
305+
__root__: [rootUser.id],
306+
[rootUser.id]: [assistantWithoutMetadata.id],
307+
},
308+
cursorId: assistantWithoutMetadata.id,
309+
messagesById: {
310+
[rootUser.id]: rootUser,
311+
[assistantWithoutMetadata.id]: assistantWithoutMetadata,
312+
},
313+
parentById: {
314+
[rootUser.id]: null,
315+
[assistantWithoutMetadata.id]: rootUser.id,
316+
},
317+
rootIds: [rootUser.id],
318+
version: 1,
319+
});
320+
321+
const assistant = store.getState().messages.at(-1);
322+
assert.equal(assistant?.metadata.parentMessageId, rootUser.id);
323+
assert.equal(
324+
assistant?.metadata.selectedModel,
325+
rootUser.metadata.selectedModel
326+
);
327+
});
328+
329+
it("adds exactly one user sibling when editing after branch navigation", () => {
330+
const userA = createMessage({
331+
id: "user-a",
332+
role: "user",
333+
createdAt: "2024-01-01T00:00:00.000Z",
334+
});
335+
const assistantA = createMessage({
336+
id: "assistant-a",
337+
role: "assistant",
338+
createdAt: "2024-01-01T00:00:01.000Z",
339+
parentMessageId: userA.id,
340+
});
341+
const userB = createMessage({
342+
id: "user-b",
343+
role: "user",
344+
createdAt: "2024-01-01T00:00:02.000Z",
345+
});
346+
const assistantB = createMessage({
347+
id: "assistant-b",
348+
role: "assistant",
349+
createdAt: "2024-01-01T00:00:03.000Z",
350+
parentMessageId: userB.id,
351+
});
352+
const userC = createMessage({
353+
id: "user-c",
354+
role: "user",
355+
createdAt: "2024-01-01T00:00:04.000Z",
356+
});
357+
const assistantC = createMessage({
358+
id: "assistant-c",
359+
role: "assistant",
360+
createdAt: "2024-01-01T00:00:05.000Z",
361+
parentMessageId: userC.id,
362+
});
363+
364+
const store = createThreadStore([userA, assistantA]);
365+
store.getState().addMessageToTree(userB);
366+
store.getState().addMessageToTree(assistantB);
367+
368+
assert.equal(
369+
store.getState().getMessageSiblingInfo(userB.id)?.siblings.length,
370+
2
371+
);
372+
373+
store.getState().switchToSibling(userB.id, "prev");
374+
store.getState().setMessages([]);
375+
store.getState().addMessageToTree(userC);
376+
store.getState().addMessageToTree(assistantC);
377+
378+
const rootSiblingIds = store
379+
.getState()
380+
.getMessageSiblingInfo(userC.id)
381+
?.siblings.map((message: ChatMessage) => message.id);
382+
383+
assert.deepEqual(rootSiblingIds, [userA.id, userB.id, userC.id]);
384+
});
137385
});

0 commit comments

Comments
 (0)