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.
@@ -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
+ })