Skip to content

Commit b70c5e6

Browse files
fix(chat): preserve thread snapshot invariants
1 parent b661db9 commit b70c5e6

2 files changed

Lines changed: 356 additions & 44 deletions

File tree

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

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,205 @@ function createThreadStore(initialMessages: ChatMessage[]) {
5757
}
5858

5959
describe("withThreads", () => {
60+
it("preserves explicit topology for messages without app metadata", () => {
61+
const root = {
62+
id: "root",
63+
parts: [{ type: "text", text: "Root" }],
64+
role: "user",
65+
} as ChatMessage;
66+
const assistant = {
67+
id: "assistant",
68+
parts: [{ type: "text", text: "Response" }],
69+
role: "assistant",
70+
} as ChatMessage;
71+
const store = createThreadStore([]);
72+
73+
store.getState().setTreeSnapshot({
74+
cursorId: assistant.id,
75+
nodes: [
76+
{ message: root, parentId: null },
77+
{ message: assistant, parentId: root.id },
78+
],
79+
version: 1,
80+
});
81+
82+
assert.deepEqual(
83+
store.getState().messages.map((message) => message.id),
84+
[root.id, assistant.id]
85+
);
86+
assert.deepEqual(
87+
store
88+
.getState()
89+
.treeSnapshot.nodes.map(({ message, parentId }) => [
90+
message.id,
91+
parentId,
92+
]),
93+
[
94+
[root.id, null],
95+
[assistant.id, root.id],
96+
]
97+
);
98+
});
99+
100+
it("treats a null cursor as an empty selected path", () => {
101+
const root = createMessage({
102+
id: "root",
103+
role: "user",
104+
createdAt: "2024-01-01T00:00:00.000Z",
105+
});
106+
const store = createThreadStore([root]);
107+
108+
store.getState().setTreeSnapshot({
109+
cursorId: null,
110+
nodes: [{ message: root, parentId: null }],
111+
version: 1,
112+
});
113+
114+
assert.deepEqual(store.getState().messages, []);
115+
assert.deepEqual(
116+
store.getState().allMessages.map((message) => message.id),
117+
[root.id]
118+
);
119+
});
120+
121+
it("preserves paths deeper than 100 messages", () => {
122+
const messages = Array.from({ length: 125 }, (_, index) =>
123+
createMessage({
124+
id: `message-${index}`,
125+
role: index % 2 === 0 ? "user" : "assistant",
126+
createdAt: new Date(index * 1000).toISOString(),
127+
parentMessageId: index === 0 ? null : `message-${index - 1}`,
128+
})
129+
);
130+
const store = createThreadStore([]);
131+
132+
store.getState().setTreeSnapshot({
133+
cursorId: messages.at(-1)?.id ?? null,
134+
nodes: messages.map((message, index) => ({
135+
message,
136+
parentId: index === 0 ? null : (messages[index - 1]?.id ?? null),
137+
})),
138+
version: 1,
139+
});
140+
141+
assert.equal(store.getState().messages.length, messages.length);
142+
assert.equal(store.getState().messages.at(0)?.id, messages.at(0)?.id);
143+
});
144+
145+
it("keeps known root parents when metadata points outside the tree", () => {
146+
const rootA = createMessage({
147+
id: "root-a",
148+
role: "user",
149+
createdAt: "2024-01-01T00:00:00.000Z",
150+
parentMessageId: "missing-parent",
151+
});
152+
const rootB = createMessage({
153+
id: "root-b",
154+
role: "user",
155+
createdAt: "2024-01-01T00:00:01.000Z",
156+
parentMessageId: "missing-parent",
157+
});
158+
const store = createThreadStore([]);
159+
160+
store.getState().setTreeSnapshot({
161+
cursorId: rootB.id,
162+
nodes: [
163+
{ message: rootA, parentId: null },
164+
{ message: rootB, parentId: null },
165+
],
166+
version: 1,
167+
});
168+
169+
assert.deepEqual(
170+
store
171+
.getState()
172+
.getMessageSiblingInfo(rootB.id)
173+
?.siblings.map((message) => message.id),
174+
[rootA.id, rootB.id]
175+
);
176+
});
177+
178+
it("hydrates an empty visible path from the server tree", () => {
179+
const root = createMessage({
180+
id: "root",
181+
role: "user",
182+
createdAt: "2024-01-01T00:00:00.000Z",
183+
});
184+
const assistant = createMessage({
185+
id: "assistant",
186+
role: "assistant",
187+
createdAt: "2024-01-01T00:00:01.000Z",
188+
parentMessageId: root.id,
189+
});
190+
const store = createThreadStore([]);
191+
192+
store.getState().setAllMessages([root, assistant]);
193+
194+
assert.deepEqual(
195+
store.getState().messages.map((message) => message.id),
196+
[root.id, assistant.id]
197+
);
198+
assert.equal(store.getState().treeSnapshot.cursorId, assistant.id);
199+
});
200+
201+
it("inherits deterministic fallback metadata from the parent", () => {
202+
const root = createMessage({
203+
id: "root",
204+
role: "user",
205+
createdAt: "2024-01-01T00:00:00.000Z",
206+
});
207+
const assistant = {
208+
id: "assistant",
209+
parts: [],
210+
role: "assistant",
211+
} as unknown as ChatMessage;
212+
const store = createThreadStore([root]);
213+
214+
store.getState().setTreeSnapshot({
215+
cursorId: assistant.id,
216+
nodes: [
217+
{ message: root, parentId: null },
218+
{ message: assistant, parentId: root.id },
219+
],
220+
version: 1,
221+
});
222+
223+
assert.deepEqual(
224+
store.getState().messages.at(-1)?.metadata.createdAt,
225+
root.metadata.createdAt
226+
);
227+
});
228+
229+
it("keeps initial messages stable across server synchronization", () => {
230+
const root = createMessage({
231+
id: "root",
232+
role: "user",
233+
createdAt: "2024-01-01T00:00:00.000Z",
234+
});
235+
const placeholder = createMessage({
236+
id: "assistant",
237+
role: "assistant",
238+
createdAt: "2024-01-01T00:00:01.000Z",
239+
parentMessageId: root.id,
240+
});
241+
const completed = createMessage({
242+
id: placeholder.id,
243+
role: "assistant",
244+
createdAt: "2024-01-01T00:00:01.000Z",
245+
parentMessageId: root.id,
246+
text: "Completed",
247+
});
248+
const store = createThreadStore([root, placeholder]);
249+
250+
store.getState().setAllMessages([root, completed]);
251+
252+
assert.deepEqual(store.getState().threadInitialMessages, [
253+
root,
254+
placeholder,
255+
]);
256+
assert.equal(store.getState().messages.at(-1)?.parts.at(0)?.type, "text");
257+
});
258+
60259
it("describes a parallel group before any assistant message exists", () => {
61260
const user = createMessage({
62261
id: "user-root",

0 commit comments

Comments
 (0)