canopy-ui 0.3.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/chat/ChatPanel.tsx +155 -0
- package/src/chat/ConnectionStatus.tsx +30 -0
- package/src/chat/MessageItem.tsx +198 -0
- package/src/chat/MessageList.tsx +145 -0
- package/src/chat/PlacementBanner.test.tsx +112 -0
- package/src/chat/PlacementBanner.tsx +92 -0
- package/src/chat/PresenceChips.tsx +52 -0
- package/src/chat/SendBox.test.tsx +359 -0
- package/src/chat/SendBox.tsx +306 -0
- package/src/chat/ToolCallPair.tsx +86 -0
- package/src/chat/drafts.test.ts +90 -0
- package/src/chat/drafts.ts +37 -0
- package/src/chat/groupToolRuns.test.ts +81 -0
- package/src/chat/groupToolRuns.ts +82 -0
- package/src/chat/history.test.ts +35 -0
- package/src/chat/history.ts +16 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/pairToolMessages.test.ts +360 -0
- package/src/chat/pairToolMessages.ts +238 -0
- package/src/chat/protocol.ts +112 -0
- package/src/chat/sessionReducer.test.ts +541 -0
- package/src/chat/sessionReducer.ts +368 -0
- package/src/chat/useSessionSocket.ts +332 -0
- package/src/chat/useStickyBottom.ts +77 -0
- package/src/presence/PresenceBadge.test.tsx +58 -0
- package/src/presence/PresenceBadge.tsx +105 -0
- package/src/presence/avatar.test.ts +42 -0
- package/src/presence/avatar.ts +36 -0
- package/src/presence/index.ts +4 -0
- package/src/presence/pageKey.test.ts +53 -0
- package/src/presence/pageKey.ts +37 -0
- package/src/presence/usePresence.test.ts +130 -0
- package/src/presence/usePresence.ts +170 -0
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import type { Draft, Message, SessionState, WsEvent } from "./protocol"
|
|
4
|
+
import { sessionReducer } from "./sessionReducer"
|
|
5
|
+
|
|
6
|
+
const baseDraft: Draft = {
|
|
7
|
+
id: "d1",
|
|
8
|
+
slot: "next",
|
|
9
|
+
status: "open",
|
|
10
|
+
body: "",
|
|
11
|
+
version: 0,
|
|
12
|
+
last_editor: 0,
|
|
13
|
+
last_edit_at: "",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeState(overrides: Partial<SessionState> = {}): SessionState {
|
|
17
|
+
return {
|
|
18
|
+
messages: [],
|
|
19
|
+
active_draft: null,
|
|
20
|
+
participants: [],
|
|
21
|
+
presence_user_ids: [],
|
|
22
|
+
current_user_id: 0,
|
|
23
|
+
...overrides,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeMessage(overrides: Partial<Message> = {}): Message {
|
|
28
|
+
return {
|
|
29
|
+
id: "1",
|
|
30
|
+
turn_index: 1,
|
|
31
|
+
role: "assistant",
|
|
32
|
+
content: {},
|
|
33
|
+
plaintext: "",
|
|
34
|
+
status: "pending",
|
|
35
|
+
error_detail: null,
|
|
36
|
+
started_at: null,
|
|
37
|
+
completed_at: null,
|
|
38
|
+
created_at: new Date().toISOString(),
|
|
39
|
+
...overrides,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("sessionReducer — chat stream", () => {
|
|
44
|
+
it("session.state replaces the whole state", () => {
|
|
45
|
+
const prev = makeState({ messages: [makeMessage()] })
|
|
46
|
+
const replacement = makeState({ current_user_id: 42 })
|
|
47
|
+
const next = sessionReducer(prev, {
|
|
48
|
+
event: "session.state",
|
|
49
|
+
data: replacement,
|
|
50
|
+
} as WsEvent)
|
|
51
|
+
expect(next).toBe(replacement)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("chat.stream_start flips a matching message to streaming", () => {
|
|
55
|
+
const m = makeMessage({ id: "7", status: "pending" })
|
|
56
|
+
const prev = makeState({ messages: [m] })
|
|
57
|
+
const next = sessionReducer(prev, {
|
|
58
|
+
event: "chat.stream_start",
|
|
59
|
+
data: { message_id: "7", turn_index: 3 },
|
|
60
|
+
} as WsEvent)
|
|
61
|
+
expect(next.messages).toHaveLength(1)
|
|
62
|
+
expect(next.messages[0].status).toBe("streaming")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("chat.stream_start for an unknown id CREATES the assistant message (upsert)", () => {
|
|
66
|
+
const m = makeMessage({ id: "7", role: "user", status: "complete" })
|
|
67
|
+
const prev = makeState({ messages: [m] })
|
|
68
|
+
const next = sessionReducer(prev, {
|
|
69
|
+
event: "chat.stream_start",
|
|
70
|
+
data: { message_id: "99", turn_index: 5 },
|
|
71
|
+
} as WsEvent)
|
|
72
|
+
expect(next.messages).toHaveLength(2)
|
|
73
|
+
expect(next.messages[1]).toMatchObject({
|
|
74
|
+
id: "99",
|
|
75
|
+
role: "assistant",
|
|
76
|
+
status: "streaming",
|
|
77
|
+
plaintext: "",
|
|
78
|
+
turn_index: 5,
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("chat.delta appends text to plaintext", () => {
|
|
83
|
+
const m = makeMessage({ id: "7", plaintext: "Hello" })
|
|
84
|
+
const prev = makeState({ messages: [m] })
|
|
85
|
+
const next = sessionReducer(prev, {
|
|
86
|
+
event: "chat.delta",
|
|
87
|
+
data: { message_id: "7", text: " world" },
|
|
88
|
+
} as WsEvent)
|
|
89
|
+
expect(next.messages[0].plaintext).toBe("Hello world")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("chat.stream_complete replaces plaintext and marks complete", () => {
|
|
93
|
+
const m = makeMessage({ id: "7", plaintext: "stale partial", status: "streaming" })
|
|
94
|
+
const prev = makeState({ messages: [m] })
|
|
95
|
+
const next = sessionReducer(prev, {
|
|
96
|
+
event: "chat.stream_complete",
|
|
97
|
+
data: { message_id: "7", plaintext: "final answer" },
|
|
98
|
+
} as WsEvent)
|
|
99
|
+
expect(next.messages[0].plaintext).toBe("final answer")
|
|
100
|
+
expect(next.messages[0].status).toBe("complete")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("chat.stream_error sets error_detail and status=error", () => {
|
|
104
|
+
const m = makeMessage({ id: "7", status: "streaming" })
|
|
105
|
+
const prev = makeState({ messages: [m] })
|
|
106
|
+
const next = sessionReducer(prev, {
|
|
107
|
+
event: "chat.stream_error",
|
|
108
|
+
data: { message_id: "7", detail: "cancelled" },
|
|
109
|
+
} as WsEvent)
|
|
110
|
+
expect(next.messages[0].status).toBe("error")
|
|
111
|
+
expect(next.messages[0].error_detail).toBe("cancelled")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("chat.stream_cancelled stamps a partial-length detail", () => {
|
|
115
|
+
const m = makeMessage({ id: "7", status: "streaming" })
|
|
116
|
+
const prev = makeState({ messages: [m] })
|
|
117
|
+
const next = sessionReducer(prev, {
|
|
118
|
+
event: "chat.stream_cancelled",
|
|
119
|
+
data: { message_id: "7", partial_len: 142 },
|
|
120
|
+
} as WsEvent)
|
|
121
|
+
expect(next.messages[0].status).toBe("error")
|
|
122
|
+
expect(next.messages[0].error_detail).toMatch(/142/)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("chat.tool_use appends a tool row carrying the block as its content", () => {
|
|
126
|
+
// The block IS the content the UI pairs and renders on — dropping the
|
|
127
|
+
// frame (the old no-op) meant a running agent's tool calls only appeared
|
|
128
|
+
// after a manual reload, which is precisely when you want to see them.
|
|
129
|
+
const prev = makeState({ messages: [makeMessage()] })
|
|
130
|
+
const next = sessionReducer(prev, {
|
|
131
|
+
event: "chat.tool_use",
|
|
132
|
+
data: {
|
|
133
|
+
parent_message_id: null,
|
|
134
|
+
tool_message_id: "seq:64",
|
|
135
|
+
turn_index: 64,
|
|
136
|
+
block: { id: "toolu_1", name: "Bash", input: { command: "ls" }, text: "" },
|
|
137
|
+
},
|
|
138
|
+
} as WsEvent)
|
|
139
|
+
expect(next.messages).toHaveLength(2)
|
|
140
|
+
const row = next.messages[1]
|
|
141
|
+
expect(row.role).toBe("tool_use")
|
|
142
|
+
expect(row.id).toBe("seq:64")
|
|
143
|
+
expect(row.turn_index).toBe(64)
|
|
144
|
+
expect(row.content).toEqual({
|
|
145
|
+
id: "toolu_1",
|
|
146
|
+
name: "Bash",
|
|
147
|
+
input: { command: "ls" },
|
|
148
|
+
text: "",
|
|
149
|
+
})
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("chat.tool_result carries the result body as plaintext", () => {
|
|
153
|
+
const prev = makeState({ messages: [] })
|
|
154
|
+
const next = sessionReducer(prev, {
|
|
155
|
+
event: "chat.tool_result",
|
|
156
|
+
data: {
|
|
157
|
+
parent_message_id: null,
|
|
158
|
+
tool_message_id: "seq:128",
|
|
159
|
+
turn_index: 128,
|
|
160
|
+
block: { tool_use_id: "toolu_1", is_error: false, text: "a.txt" },
|
|
161
|
+
},
|
|
162
|
+
} as WsEvent)
|
|
163
|
+
expect(next.messages[0].role).toBe("tool_result")
|
|
164
|
+
expect(next.messages[0].plaintext).toBe("a.txt")
|
|
165
|
+
expect(next.messages[0].status).toBe("complete")
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("a failed tool result is marked error so the pair renders as one", () => {
|
|
169
|
+
const next = sessionReducer(makeState(), {
|
|
170
|
+
event: "chat.tool_result",
|
|
171
|
+
data: {
|
|
172
|
+
parent_message_id: null,
|
|
173
|
+
tool_message_id: "seq:128",
|
|
174
|
+
turn_index: 128,
|
|
175
|
+
block: { tool_use_id: "toolu_1", is_error: true, text: "boom" },
|
|
176
|
+
},
|
|
177
|
+
} as WsEvent)
|
|
178
|
+
expect(next.messages[0].status).toBe("error")
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("a re-delivered tool frame upserts instead of doubling the row", () => {
|
|
182
|
+
// Reconnect catch-up and a retried post both re-ship rows; a duplicated
|
|
183
|
+
// tool_use would leave one copy permanently stuck showing "running…".
|
|
184
|
+
const frame = {
|
|
185
|
+
event: "chat.tool_use",
|
|
186
|
+
data: {
|
|
187
|
+
parent_message_id: null,
|
|
188
|
+
tool_message_id: "seq:64",
|
|
189
|
+
turn_index: 64,
|
|
190
|
+
block: { id: "toolu_1", name: "Bash", input: {}, text: "" },
|
|
191
|
+
},
|
|
192
|
+
} as WsEvent
|
|
193
|
+
const once = sessionReducer(makeState(), frame)
|
|
194
|
+
const twice = sessionReducer(once, frame)
|
|
195
|
+
expect(twice.messages).toHaveLength(1)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it("a tool frame without an ordinal still lands after the newest row", () => {
|
|
199
|
+
const prev = makeState({ messages: [makeMessage({ turn_index: 7 })] })
|
|
200
|
+
const next = sessionReducer(prev, {
|
|
201
|
+
event: "chat.tool_use",
|
|
202
|
+
data: { parent_message_id: null, tool_message_id: "t1", block: {} },
|
|
203
|
+
} as WsEvent)
|
|
204
|
+
expect(next.messages[1].turn_index).toBe(8)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe("sessionReducer — drafts", () => {
|
|
209
|
+
it("draft.updated keeps local body when echo's last_editor matches current_user_id", () => {
|
|
210
|
+
// Echo-suppression: server echo arrives stale relative to the user's
|
|
211
|
+
// own keystrokes; reducer must keep the local body and only accept
|
|
212
|
+
// metadata. This is the most subtle branch in the file.
|
|
213
|
+
const prev = makeState({
|
|
214
|
+
current_user_id: 5,
|
|
215
|
+
active_draft: { ...baseDraft, body: "local typing", version: 3 },
|
|
216
|
+
})
|
|
217
|
+
const next = sessionReducer(prev, {
|
|
218
|
+
event: "draft.updated",
|
|
219
|
+
data: {
|
|
220
|
+
...baseDraft,
|
|
221
|
+
body: "stale server echo",
|
|
222
|
+
last_editor: 5,
|
|
223
|
+
version: 3,
|
|
224
|
+
} as Draft,
|
|
225
|
+
} as WsEvent)
|
|
226
|
+
expect(next.active_draft?.body).toBe("local typing")
|
|
227
|
+
expect(next.active_draft?.version).toBe(3)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it("draft.updated accepts the body when another user is editing", () => {
|
|
231
|
+
const prev = makeState({
|
|
232
|
+
current_user_id: 5,
|
|
233
|
+
active_draft: { ...baseDraft, body: "old", last_editor: 7 },
|
|
234
|
+
})
|
|
235
|
+
const next = sessionReducer(prev, {
|
|
236
|
+
event: "draft.updated",
|
|
237
|
+
data: {
|
|
238
|
+
...baseDraft,
|
|
239
|
+
body: "their text",
|
|
240
|
+
last_editor: 7,
|
|
241
|
+
version: 2,
|
|
242
|
+
} as Draft,
|
|
243
|
+
} as WsEvent)
|
|
244
|
+
expect(next.active_draft?.body).toBe("their text")
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("draft.committed inserts the optimistic user message and clears the draft body", () => {
|
|
248
|
+
// canopy adaptation: NO assistant placeholder here (draft.committed has
|
|
249
|
+
// no assistant id) — only the user message is inserted; the assistant is
|
|
250
|
+
// upserted later on chat.stream_start.
|
|
251
|
+
const prev = makeState({
|
|
252
|
+
active_draft: { ...baseDraft, body: "the prompt" },
|
|
253
|
+
messages: [makeMessage({ id: "1", turn_index: 1 })],
|
|
254
|
+
})
|
|
255
|
+
const next = sessionReducer(prev, {
|
|
256
|
+
event: "draft.committed",
|
|
257
|
+
data: { user_message_id: "100", draft_id: "d1" },
|
|
258
|
+
} as WsEvent)
|
|
259
|
+
expect(next.messages).toHaveLength(2)
|
|
260
|
+
expect(next.messages[1]).toMatchObject({
|
|
261
|
+
id: "100",
|
|
262
|
+
role: "user",
|
|
263
|
+
plaintext: "the prompt",
|
|
264
|
+
turn_index: 2,
|
|
265
|
+
})
|
|
266
|
+
// active_draft.body cleared so Enter doesn't re-send the same turn.
|
|
267
|
+
expect(next.active_draft?.body).toBe("")
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it("draft.committed then chat.stream_start makes the assistant reply visible", () => {
|
|
271
|
+
// The load-bearing sequence: commit inserts the user msg, stream_start
|
|
272
|
+
// upserts the assistant row, delta/complete fill it in.
|
|
273
|
+
let s = makeState({ active_draft: { ...baseDraft, body: "hi" } })
|
|
274
|
+
s = sessionReducer(s, {
|
|
275
|
+
event: "draft.committed",
|
|
276
|
+
data: { user_message_id: "u1", draft_id: "d1" },
|
|
277
|
+
} as WsEvent)
|
|
278
|
+
s = sessionReducer(s, {
|
|
279
|
+
event: "chat.stream_start",
|
|
280
|
+
data: { message_id: "a1", turn_index: 2 },
|
|
281
|
+
} as WsEvent)
|
|
282
|
+
s = sessionReducer(s, {
|
|
283
|
+
event: "chat.stream_complete",
|
|
284
|
+
data: { message_id: "a1", plaintext: "hello there" },
|
|
285
|
+
} as WsEvent)
|
|
286
|
+
expect(s.messages.map((m) => m.role)).toEqual(["user", "assistant"])
|
|
287
|
+
expect(s.messages[1].plaintext).toBe("hello there")
|
|
288
|
+
expect(s.messages[1].status).toBe("complete")
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it("draft.discarded clears matching draft body", () => {
|
|
292
|
+
const prev = makeState({
|
|
293
|
+
active_draft: { ...baseDraft, id: "d1", body: "draft text" },
|
|
294
|
+
})
|
|
295
|
+
const next = sessionReducer(prev, {
|
|
296
|
+
event: "draft.discarded",
|
|
297
|
+
data: { draft_id: "d1" },
|
|
298
|
+
} as WsEvent)
|
|
299
|
+
expect(next.active_draft?.body).toBe("")
|
|
300
|
+
})
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
describe("sessionReducer — presence", () => {
|
|
304
|
+
it("presence.joined adds a user_id idempotently", () => {
|
|
305
|
+
const prev = makeState({ presence_user_ids: [1, 2] })
|
|
306
|
+
const next = sessionReducer(prev, {
|
|
307
|
+
event: "presence.joined",
|
|
308
|
+
data: { user_id: 3 },
|
|
309
|
+
} as WsEvent)
|
|
310
|
+
expect(next.presence_user_ids.sort()).toEqual([1, 2, 3])
|
|
311
|
+
|
|
312
|
+
// Second join is a no-op (Set semantics).
|
|
313
|
+
const after = sessionReducer(next, {
|
|
314
|
+
event: "presence.joined",
|
|
315
|
+
data: { user_id: 3 },
|
|
316
|
+
} as WsEvent)
|
|
317
|
+
expect(after.presence_user_ids.filter((id) => id === 3)).toHaveLength(1)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("presence.left filters out the user_id", () => {
|
|
321
|
+
const prev = makeState({ presence_user_ids: [1, 2, 3] })
|
|
322
|
+
const next = sessionReducer(prev, {
|
|
323
|
+
event: "presence.left",
|
|
324
|
+
data: { user_id: 2 },
|
|
325
|
+
} as WsEvent)
|
|
326
|
+
expect(next.presence_user_ids).toEqual([1, 3])
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
describe("sessionReducer — session.error draft_version_mismatch", () => {
|
|
331
|
+
it("rolls active_draft back to the server's reported version + body", () => {
|
|
332
|
+
const prev = makeState({
|
|
333
|
+
active_draft: { ...baseDraft, version: 9, body: "stale local" },
|
|
334
|
+
})
|
|
335
|
+
const next = sessionReducer(prev, {
|
|
336
|
+
event: "session.error",
|
|
337
|
+
data: {
|
|
338
|
+
message: "version mismatch",
|
|
339
|
+
code: "draft_version_mismatch",
|
|
340
|
+
detail: { current_version: 11, current_body: "server body" },
|
|
341
|
+
},
|
|
342
|
+
} as WsEvent)
|
|
343
|
+
expect(next.active_draft?.version).toBe(11)
|
|
344
|
+
expect(next.active_draft?.body).toBe("server body")
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it("non-version-mismatch errors are no-ops to state (side-effect handled by hook)", () => {
|
|
348
|
+
const prev = makeState({
|
|
349
|
+
active_draft: { ...baseDraft, body: "x" },
|
|
350
|
+
})
|
|
351
|
+
const next = sessionReducer(prev, {
|
|
352
|
+
event: "session.error",
|
|
353
|
+
data: { message: "something else", code: "other" },
|
|
354
|
+
} as WsEvent)
|
|
355
|
+
expect(next).toBe(prev)
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
describe("sessionReducer — live/durable reconciliation", () => {
|
|
360
|
+
const liveToolUse = {
|
|
361
|
+
event: "chat.tool_use",
|
|
362
|
+
data: {
|
|
363
|
+
parent_message_id: null,
|
|
364
|
+
tool_message_id: "seq:-1",
|
|
365
|
+
turn_index: -1,
|
|
366
|
+
block: { id: "toolu_9", name: "Bash", input: { command: "ls" }, text: "" },
|
|
367
|
+
},
|
|
368
|
+
} as WsEvent
|
|
369
|
+
|
|
370
|
+
const durableToolUse = {
|
|
371
|
+
event: "chat.tool_use",
|
|
372
|
+
data: {
|
|
373
|
+
parent_message_id: null,
|
|
374
|
+
tool_message_id: "42",
|
|
375
|
+
turn_index: 128,
|
|
376
|
+
block: { id: "toolu_9", name: "Bash", input: { command: "ls" }, text: "" },
|
|
377
|
+
},
|
|
378
|
+
} as WsEvent
|
|
379
|
+
|
|
380
|
+
it("a durable row REPLACES the live placeholder for the same tool call", () => {
|
|
381
|
+
// The same call arrives twice by design — live from a hook (no ordinal) and
|
|
382
|
+
// durably from the transcript. They share only tool_use_id, so without
|
|
383
|
+
// reconciling on it the user sees every tool call twice.
|
|
384
|
+
const live = sessionReducer(makeState(), liveToolUse)
|
|
385
|
+
expect(live.messages).toHaveLength(1)
|
|
386
|
+
const settled = sessionReducer(live, durableToolUse)
|
|
387
|
+
expect(settled.messages).toHaveLength(1)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it("the surviving row takes the durable identity, not the placeholder's", () => {
|
|
391
|
+
// Otherwise later updates key on a `seq:-1` that no longer means anything.
|
|
392
|
+
const settled = sessionReducer(
|
|
393
|
+
sessionReducer(makeState(), liveToolUse),
|
|
394
|
+
durableToolUse,
|
|
395
|
+
)
|
|
396
|
+
expect(settled.messages[0].id).toBe("42")
|
|
397
|
+
expect(settled.messages[0].turn_index).toBe(128)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
it("reconciles tool_result on tool_use_id too", () => {
|
|
401
|
+
const mk = (id: string, turn_index: number) =>
|
|
402
|
+
({
|
|
403
|
+
event: "chat.tool_result",
|
|
404
|
+
data: {
|
|
405
|
+
parent_message_id: null,
|
|
406
|
+
tool_message_id: id,
|
|
407
|
+
turn_index,
|
|
408
|
+
block: { tool_use_id: "toolu_9", is_error: false, text: "a.txt" },
|
|
409
|
+
},
|
|
410
|
+
}) as WsEvent
|
|
411
|
+
const settled = sessionReducer(
|
|
412
|
+
sessionReducer(makeState(), mk("seq:-1", -1)),
|
|
413
|
+
mk("77", 192),
|
|
414
|
+
)
|
|
415
|
+
expect(settled.messages).toHaveLength(1)
|
|
416
|
+
expect(settled.messages[0].id).toBe("77")
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it("two different tool calls stay two rows", () => {
|
|
420
|
+
const other = {
|
|
421
|
+
event: "chat.tool_use",
|
|
422
|
+
data: {
|
|
423
|
+
parent_message_id: null,
|
|
424
|
+
tool_message_id: "seq:-1b",
|
|
425
|
+
turn_index: -1,
|
|
426
|
+
block: { id: "toolu_OTHER", name: "Read", input: {}, text: "" },
|
|
427
|
+
},
|
|
428
|
+
} as WsEvent
|
|
429
|
+
const next = sessionReducer(sessionReducer(makeState(), liveToolUse), other)
|
|
430
|
+
expect(next.messages).toHaveLength(2)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it("a row with no correlation id still falls back to matching on message id", () => {
|
|
434
|
+
const noId = {
|
|
435
|
+
event: "chat.tool_use",
|
|
436
|
+
data: { parent_message_id: null, tool_message_id: "t1", block: {} },
|
|
437
|
+
} as WsEvent
|
|
438
|
+
const next = sessionReducer(sessionReducer(makeState(), noId), noId)
|
|
439
|
+
expect(next.messages).toHaveLength(1)
|
|
440
|
+
})
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
describe("sessionReducer — pending → complete lifecycle", () => {
|
|
444
|
+
const mk = (status: string, id = "seq:-1") =>
|
|
445
|
+
({
|
|
446
|
+
event: "chat.tool_use",
|
|
447
|
+
data: {
|
|
448
|
+
parent_message_id: null,
|
|
449
|
+
tool_message_id: id,
|
|
450
|
+
turn_index: -1,
|
|
451
|
+
block: { id: "toolu_LIVE", name: "Bash", input: { command: "npm test" }, status, text: "" },
|
|
452
|
+
},
|
|
453
|
+
}) as WsEvent
|
|
454
|
+
|
|
455
|
+
it("a pending row from PreToolUse appears immediately, with no result", () => {
|
|
456
|
+
// The whole point: a long tool call should read as RUNNING, not as nothing
|
|
457
|
+
// happening. Before PreToolUse was forwarded, the row only appeared once the
|
|
458
|
+
// call had already finished.
|
|
459
|
+
const next = sessionReducer(makeState(), mk("pending"))
|
|
460
|
+
expect(next.messages).toHaveLength(1)
|
|
461
|
+
expect(next.messages[0].role).toBe("tool_use")
|
|
462
|
+
expect(next.messages[0].content.status).toBe("pending")
|
|
463
|
+
// No tool_result row — that's what makes ToolCallPair render "running…".
|
|
464
|
+
expect(next.messages.some((m) => m.role === "tool_result")).toBe(false)
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it("the completed row REPLACES the pending one rather than doubling it", () => {
|
|
468
|
+
const pending = sessionReducer(makeState(), mk("pending"))
|
|
469
|
+
const done = sessionReducer(pending, mk("complete", "seq:-1"))
|
|
470
|
+
expect(done.messages).toHaveLength(1)
|
|
471
|
+
expect(done.messages[0].content.status).toBe("complete")
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it("the durable transcript row then supersedes both", () => {
|
|
475
|
+
// Three deliveries of one call — pending hook, completed hook, transcript —
|
|
476
|
+
// must converge on a single row carrying the durable identity.
|
|
477
|
+
const durable = {
|
|
478
|
+
event: "chat.tool_use",
|
|
479
|
+
data: {
|
|
480
|
+
parent_message_id: null,
|
|
481
|
+
tool_message_id: "991",
|
|
482
|
+
turn_index: 4096,
|
|
483
|
+
block: { id: "toolu_LIVE", name: "Bash", input: { command: "npm test" }, text: "" },
|
|
484
|
+
},
|
|
485
|
+
} as WsEvent
|
|
486
|
+
const settled = sessionReducer(
|
|
487
|
+
sessionReducer(sessionReducer(makeState(), mk("pending")), mk("complete")),
|
|
488
|
+
durable,
|
|
489
|
+
)
|
|
490
|
+
expect(settled.messages).toHaveLength(1)
|
|
491
|
+
expect(settled.messages[0].id).toBe("991")
|
|
492
|
+
expect(settled.messages[0].turn_index).toBe(4096)
|
|
493
|
+
})
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
describe("sessionReducer — a web send arriving twice", () => {
|
|
497
|
+
it("does not render the same message twice at two different ordinals", () => {
|
|
498
|
+
// THE live bug (2026-07-27): a web send writes its row at a DENSE index
|
|
499
|
+
// (_next_index), then the agent reads it and the transcript re-ships the
|
|
500
|
+
// same text at a COMPOSITE ordinal. Different index, different id, so
|
|
501
|
+
// matching on turn_index alone missed and the message appeared twice — while
|
|
502
|
+
// a reload showed it once, because get_or_create dedupes server-side.
|
|
503
|
+
const seeded = makeState({
|
|
504
|
+
messages: [
|
|
505
|
+
makeMessage({ id: "501", turn_index: 37, role: "user", plaintext: "try one more time" }),
|
|
506
|
+
],
|
|
507
|
+
})
|
|
508
|
+
const fromTranscript = {
|
|
509
|
+
event: "chat.user_message",
|
|
510
|
+
data: { message_id: "902", turn_index: 144448, plaintext: "try one more time" },
|
|
511
|
+
} as WsEvent
|
|
512
|
+
const next = sessionReducer(seeded, fromTranscript)
|
|
513
|
+
expect(next.messages).toHaveLength(1)
|
|
514
|
+
// And it adopts the durable ordinal, so it sorts where a reload puts it.
|
|
515
|
+
expect(next.messages[0].turn_index).toBe(144448)
|
|
516
|
+
expect(next.messages[0].id).toBe("902")
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it("a genuine repeat sent much later is still its own row", () => {
|
|
520
|
+
// The dedupe must not swallow real repetition — only the tail is compared.
|
|
521
|
+
const many = Array.from({ length: 9 }, (_, i) =>
|
|
522
|
+
makeMessage({ id: `u${i}`, turn_index: i, role: "user", plaintext: i === 0 ? "yes" : `m${i}` }),
|
|
523
|
+
)
|
|
524
|
+
const next = sessionReducer(makeState({ messages: many }), {
|
|
525
|
+
event: "chat.user_message",
|
|
526
|
+
data: { message_id: "new", turn_index: 500, plaintext: "yes" },
|
|
527
|
+
} as WsEvent)
|
|
528
|
+
expect(next.messages).toHaveLength(10)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it("an empty-text frame never collapses onto another empty row", () => {
|
|
532
|
+
const seeded = makeState({
|
|
533
|
+
messages: [makeMessage({ id: "1", turn_index: 1, role: "user", plaintext: "" })],
|
|
534
|
+
})
|
|
535
|
+
const next = sessionReducer(seeded, {
|
|
536
|
+
event: "chat.user_message",
|
|
537
|
+
data: { message_id: "2", turn_index: 2, plaintext: "" },
|
|
538
|
+
} as WsEvent)
|
|
539
|
+
expect(next.messages).toHaveLength(2)
|
|
540
|
+
})
|
|
541
|
+
})
|