canopy-ui 0.12.2 → 0.13.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.
@@ -62,7 +62,11 @@
62
62
  "data": {
63
63
  "message_id": "u1",
64
64
  "turn_index": 3,
65
- "plaintext": "hi there"
65
+ "plaintext": "hi there",
66
+ "author": {
67
+ "name": "Jon",
68
+ "user_id": 7
69
+ }
66
70
  }
67
71
  },
68
72
  "agui": [
@@ -70,7 +74,11 @@
70
74
  "type": "TEXT_MESSAGE_CHUNK",
71
75
  "metadata": {
72
76
  "canopy": {
73
- "turn_index": 3
77
+ "turn_index": 3,
78
+ "author": {
79
+ "name": "Jon",
80
+ "user_id": 7
81
+ }
74
82
  }
75
83
  },
76
84
  "messageId": "u1",
@@ -198,6 +206,33 @@
198
206
  }
199
207
  ]
200
208
  },
209
+ {
210
+ "canopy": {
211
+ "event": "draft.typing",
212
+ "data": {
213
+ "author": {
214
+ "id": 7,
215
+ "name": "Beth"
216
+ },
217
+ "body": "wip",
218
+ "at": "2026-09-26T12:00:00+00:00"
219
+ }
220
+ },
221
+ "agui": [
222
+ {
223
+ "type": "CUSTOM",
224
+ "name": "canopy.draft.typing",
225
+ "value": {
226
+ "author": {
227
+ "id": 7,
228
+ "name": "Beth"
229
+ },
230
+ "body": "wip",
231
+ "at": "2026-09-26T12:00:00+00:00"
232
+ }
233
+ }
234
+ ]
235
+ },
201
236
  {
202
237
  "canopy": {
203
238
  "event": "presence.joined",
@@ -441,6 +476,47 @@
441
476
  }
442
477
  ]
443
478
  },
479
+ {
480
+ "canopy": {
481
+ "event": "session.queued",
482
+ "data": {
483
+ "queued": [
484
+ {
485
+ "turn_id": "8f2e",
486
+ "client_id": "c9",
487
+ "author": {
488
+ "name": "Jon",
489
+ "user_id": 7
490
+ },
491
+ "text": "from jj",
492
+ "sent_at": "2026-09-26T12:00:00+00:00",
493
+ "state": "queued"
494
+ }
495
+ ]
496
+ }
497
+ },
498
+ "agui": [
499
+ {
500
+ "type": "CUSTOM",
501
+ "name": "canopy.session.queued",
502
+ "value": {
503
+ "queued": [
504
+ {
505
+ "turn_id": "8f2e",
506
+ "client_id": "c9",
507
+ "author": {
508
+ "name": "Jon",
509
+ "user_id": 7
510
+ },
511
+ "text": "from jj",
512
+ "sent_at": "2026-09-26T12:00:00+00:00",
513
+ "state": "queued"
514
+ }
515
+ ]
516
+ }
517
+ }
518
+ ]
519
+ },
444
520
  {
445
521
  "canopy": {
446
522
  "event": "session.stop",
@@ -614,27 +690,6 @@
614
690
  }
615
691
  ]
616
692
  },
617
- {
618
- "canopy": {
619
- "event": "draft.lock_changed",
620
- "data": {
621
- "draft_id": "d1",
622
- "holder_user_id": 7,
623
- "expires_at": "2026-09-18T12:01:00+00:00"
624
- }
625
- },
626
- "agui": [
627
- {
628
- "type": "CUSTOM",
629
- "name": "canopy.draft.lock_changed",
630
- "value": {
631
- "draft_id": "d1",
632
- "holder_user_id": 7,
633
- "expires_at": "2026-09-18T12:01:00+00:00"
634
- }
635
- }
636
- ]
637
- },
638
693
  {
639
694
  "canopy": {
640
695
  "event": "page.invalidate",
package/src/chat/agui.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * standard.
28
28
  */
29
29
 
30
- import type { SessionMenu, WsEvent } from "./protocol";
30
+ import type { MessageAuthor, SessionMenu, WsEvent } from "./protocol";
31
31
 
32
32
  /** Where canopy's own fields ride. Mirrors `agui.METADATA_KEY`. */
33
33
  export const METADATA_KEY = "canopy";
@@ -117,6 +117,11 @@ export function fromAgui(frame: AguiFrame): WsEvent[] {
117
117
  message_id: str(frame.messageId),
118
118
  turn_index: num(m.turn_index),
119
119
  plaintext: str(frame.delta),
120
+ // Who typed it — a transcript-sourced session's durable row carries
121
+ // no user beyond the runner's own login, so this metadata is
122
+ // canopy's only chance to say (spec 2026-09-26). Absent when the
123
+ // server sent none (an agent/scheduled/email turn's own send).
124
+ author: m.author as MessageAuthor | undefined,
120
125
  },
121
126
  },
122
127
  ];
@@ -1,13 +1,9 @@
1
- import { afterEach, describe, expect, it, vi } from "vitest"
1
+ import { describe, expect, it } from "vitest"
2
2
 
3
- import type { Draft } from "./protocol"
4
3
  import {
5
4
  DRAFT_STORAGE_TTL_MS,
6
- IDLE_THRESHOLD_MS,
7
5
  clearStoredDraft,
8
6
  draftStorageKey,
9
- isDraftIdle,
10
- msUntilDraftIdle,
11
7
  readStoredDraft,
12
8
  shouldSyncDraftLive,
13
9
  writeStoredDraft,
@@ -15,66 +11,6 @@ import {
15
11
 
16
12
  const NOW = 1_700_000_000_000
17
13
 
18
- function draftEditedAt(msAgo: number): Draft {
19
- return {
20
- id: "d1",
21
- slot: "next",
22
- status: "open",
23
- body: "",
24
- version: 0,
25
- last_editor: 1,
26
- last_edit_at: new Date(NOW - msAgo).toISOString(),
27
- }
28
- }
29
-
30
- afterEach(() => {
31
- vi.useRealTimers()
32
- })
33
-
34
- describe("isDraftIdle", () => {
35
- it("treats a null/undefined draft as idle", () => {
36
- expect(isDraftIdle(null)).toBe(true)
37
- expect(isDraftIdle(undefined)).toBe(true)
38
- })
39
-
40
- it("treats a draft with no last_edit_at as idle", () => {
41
- const d = { ...draftEditedAt(0), last_edit_at: "" }
42
- expect(isDraftIdle(d)).toBe(true)
43
- })
44
-
45
- it("is NOT idle immediately after an edit", () => {
46
- vi.useFakeTimers()
47
- vi.setSystemTime(NOW)
48
- // edited 500ms ago — well within the 2s threshold
49
- expect(isDraftIdle(draftEditedAt(500))).toBe(false)
50
- })
51
-
52
- it("IS idle once more than the threshold has elapsed", () => {
53
- vi.useFakeTimers()
54
- vi.setSystemTime(NOW)
55
- expect(isDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 1))).toBe(true)
56
- })
57
- })
58
-
59
- describe("msUntilDraftIdle", () => {
60
- it("returns 0 for a null draft", () => {
61
- expect(msUntilDraftIdle(null)).toBe(0)
62
- })
63
-
64
- it("returns the remaining time before the idle transition", () => {
65
- vi.useFakeTimers()
66
- vi.setSystemTime(NOW)
67
- // edited 500ms ago → 1500ms remain
68
- expect(msUntilDraftIdle(draftEditedAt(500))).toBe(1500)
69
- })
70
-
71
- it("clamps to 0 once past the threshold", () => {
72
- vi.useFakeTimers()
73
- vi.setSystemTime(NOW)
74
- expect(msUntilDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 5000))).toBe(0)
75
- })
76
- })
77
-
78
14
  describe("shouldSyncDraftLive", () => {
79
15
  it("does not mirror keystrokes when you are alone", () => {
80
16
  // The single-player case: a per-keystroke draft.update costs a round trip,
@@ -1,12 +1,3 @@
1
- import type { Draft } from "./protocol";
2
-
3
- export const IDLE_THRESHOLD_MS = 2_000;
4
-
5
- export function isDraftIdle(draft: Draft | null | undefined): boolean {
6
- if (!draft?.last_edit_at) return true;
7
- return Date.now() - new Date(draft.last_edit_at).getTime() > IDLE_THRESHOLD_MS;
8
- }
9
-
10
1
  /**
11
2
  * Whether keystrokes need to be mirrored to the server AS YOU TYPE.
12
3
  *
@@ -25,17 +16,6 @@ export function shouldSyncDraftLive(presenceUserIds: readonly number[]): boolean
25
16
  return presenceUserIds.length > 1;
26
17
  }
27
18
 
28
- /**
29
- * Milliseconds until the draft lock becomes idle. Returns 0 if already
30
- * idle. Use this to schedule a timer that forces a re-render at the
31
- * idle transition point.
32
- */
33
- export function msUntilDraftIdle(draft: Draft | null | undefined): number {
34
- if (!draft?.last_edit_at) return 0;
35
- const elapsed = Date.now() - new Date(draft.last_edit_at).getTime();
36
- return Math.max(0, IDLE_THRESHOLD_MS - elapsed);
37
- }
38
-
39
19
  // ---------------------------------------------------------------------------
40
20
  // Composer persistence — surviving a navigation the server never hears about.
41
21
  //
@@ -0,0 +1,37 @@
1
+ import type { Message, MessageAuthor } from "./protocol";
2
+
3
+ /** Is this line the viewer's own? A viewer is a canopy user (`currentUserId`)
4
+ * OR a contact — a widget visitor, who has no user id and is named by the
5
+ * snapshot's `current_contact_id` instead. Comparing user ids alone made every
6
+ * line a contact wrote render as someone else's, under their own name. */
7
+ export function isMine(
8
+ author: MessageAuthor | null | undefined,
9
+ currentUserId: number | null | undefined,
10
+ currentContactId: number | null | undefined,
11
+ ): boolean {
12
+ if (!author) return false;
13
+ return (
14
+ (author.user_id != null && author.user_id === currentUserId) ||
15
+ (author.contact_id != null && author.contact_id === currentContactId)
16
+ );
17
+ }
18
+
19
+ /** The same person, or no way to tell (either side unauthored). */
20
+ export function sameAuthor(
21
+ a: MessageAuthor | null | undefined,
22
+ b: MessageAuthor | null | undefined,
23
+ ): boolean {
24
+ if (!a || !b) return true;
25
+ if (a.user_id != null || b.user_id != null) return a.user_id === b.user_id;
26
+ return a.contact_id === b.contact_id;
27
+ }
28
+
29
+ /** A user row still waiting for the durable line it will become: an optimistic
30
+ * send (`local:`), a receipted transcript-sourced send (`transient:` — the
31
+ * server wrote no row, the transcript will), or one still pending/failed. */
32
+ export function isUnconfirmed(m: Message): boolean {
33
+ return (
34
+ m.status === "pending" || m.status === "error" ||
35
+ m.id.startsWith("local:") || m.id.startsWith("transient:")
36
+ );
37
+ }
package/src/chat/index.ts CHANGED
@@ -7,9 +7,12 @@
7
7
  // Protocol types
8
8
  export type {
9
9
  Message,
10
+ MessageAuthor,
10
11
  MessageRole,
11
12
  MessageStatus,
12
13
  Draft,
14
+ PeerDraft,
15
+ QueuedMessage,
13
16
  Participant,
14
17
  SessionMenu,
15
18
  SessionState,
@@ -29,6 +32,7 @@ export { prependHistory } from "./history";
29
32
  // Hooks
30
33
  export {
31
34
  useSessionSocket,
35
+ newClientId,
32
36
  type UseSessionSocketOptions,
33
37
  type UseSessionSocketResult,
34
38
  } from "./useSessionSocket";
@@ -45,9 +49,6 @@ export {
45
49
  type TurnNotice,
46
50
  } from "./turnStatus";
47
51
 
48
- // Draft idle helpers
49
- export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
50
-
51
52
  // Composer persistence (survives unmount / tab close)
52
53
  export {
53
54
  DRAFT_STORAGE_TTL_MS,
@@ -77,6 +78,9 @@ export { MessageItem, type RenderMarkdown } from "./MessageItem";
77
78
  export { ToolCallPair } from "./ToolCallPair";
78
79
  export { SendBox, type PendingAttachment } from "./SendBox";
79
80
  export { PresenceChips } from "./PresenceChips";
81
+ export { TypingRows } from "./TypingRows";
82
+ export { QueuedRows } from "./QueuedRows";
83
+ export { isMine } from "./identity";
80
84
  export { ConnectionStatus } from "./ConnectionStatus";
81
85
  export {
82
86
  PlacementBanner,
@@ -26,6 +26,14 @@ export type MessageRole =
26
26
  // Session + message shapes (the `session.state` snapshot payload)
27
27
  // ---------------------------------------------------------------------------
28
28
 
29
+ /** Who sent a human line — a canopy user or a contact, never both.
30
+ * Mirrors `apps/canopy_sessions/authorship.py::parse`'s return shape. */
31
+ export interface MessageAuthor {
32
+ name: string;
33
+ user_id?: number;
34
+ contact_id?: number;
35
+ }
36
+
29
37
  export interface Message {
30
38
  /** String PK (canopy sends `str(msg.pk)`), or a synthetic stream id. */
31
39
  id: string;
@@ -38,6 +46,9 @@ export interface Message {
38
46
  started_at: string | null;
39
47
  completed_at: string | null;
40
48
  created_at: string;
49
+ /** Who typed this line — a canopy user or a contact. Absent/null on rows
50
+ * from before authorship was tracked, or on the agent's own messages. */
51
+ author?: MessageAuthor | null;
41
52
  }
42
53
 
43
54
  export interface Draft {
@@ -49,6 +60,34 @@ export interface Draft {
49
60
  version: number;
50
61
  last_editor: number;
51
62
  last_edit_at: string;
63
+ /** The draft's OWNER — distinct from `last_editor`, which is whoever most
64
+ * recently typed in it. Since a `draft.updated` frame now only ever
65
+ * reaches its author's own sockets, this is the reliable field to compare
66
+ * against `current_user_id` for echo-suppression; `last_editor` is kept
67
+ * for servers that don't send it yet. */
68
+ author_id?: number;
69
+ }
70
+
71
+ /** A peer's live, uncommitted draft — everyone else's box, broadcast as they
72
+ * type. Keyed by author, never by draft id: a client only ever needs "what
73
+ * is this person typing right now", and an empty `body` means they sent,
74
+ * discarded, or cleared it. */
75
+ export interface PeerDraft {
76
+ author: { id: number; name: string };
77
+ body: string;
78
+ at: string | null;
79
+ }
80
+
81
+ /** A send that landed on the server but hasn't reached the agent's turn yet
82
+ * — the server-derived queue, replacing whatever a client used to infer
83
+ * locally from its own optimistic sends. */
84
+ export interface QueuedMessage {
85
+ turn_id: string;
86
+ client_id: string;
87
+ author: MessageAuthor | null;
88
+ text: string;
89
+ sent_at: string;
90
+ state: "queued" | "delivering";
52
91
  }
53
92
 
54
93
  export interface Participant {
@@ -197,10 +236,23 @@ export interface SessionState {
197
236
  * changed. Null/undefined when nothing has been asked on this session yet —
198
237
  * which is distinct from "finished", and must stay distinct. */
199
238
  turn_status?: TurnStatus | null;
239
+ /** The CALLER's own draft — null for a viewer, who has nothing to draft. */
200
240
  active_draft: Draft | null;
241
+ /** Everyone else's live drafts, server-derived. Absent on an older server. */
242
+ peer_drafts?: PeerDraft[];
243
+ /** Sends that landed but haven't reached the agent's turn yet, server-
244
+ * derived and replaced wholesale on every frame — like `turn_status`, a
245
+ * client that just connected has no correct prior to merge a delta into.
246
+ * Absent on an older server. */
247
+ queued?: QueuedMessage[];
201
248
  participants: Participant[];
202
249
  presence_user_ids: number[];
203
- current_user_id: number;
250
+ /** The connecting user — null for a contact (a widget visitor), who has no
251
+ * user id. */
252
+ current_user_id: number | null;
253
+ /** The connecting CONTACT, when the viewer is one; null for a member.
254
+ * Absent on an older server. */
255
+ current_contact_id?: number | null;
204
256
  }
205
257
 
206
258
  // ---------------------------------------------------------------------------
@@ -211,6 +263,8 @@ export type WsAction =
211
263
  | { action: "chat.send"; data: Record<string, never> }
212
264
  | { action: "chat.stop"; data: { message_id: string } }
213
265
  | { action: "draft.update"; data: { version: number; body: string } }
266
+ /** @deprecated accepted and ignored by the server since 0.13 — every editor
267
+ * gets their own draft now, so there is no lock left to take over. */
214
268
  | { action: "draft.take_over"; data: Record<string, never> }
215
269
  | { action: "draft.discard"; data: Record<string, never> }
216
270
  | { action: "presence.heartbeat"; data: Record<string, never> };
@@ -238,8 +292,17 @@ export type WsEvent =
238
292
  // offline box, unroutable) are ones activity has no way to spell.
239
293
  | { event: "session.turn_status"; data: { status: TurnStatus | null } }
240
294
  // A human typed into emdash rather than into this page. No client echoed it,
241
- // so this is the only way it reaches the browser before a reload.
242
- | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
295
+ // so this is the only way it reaches the browser before a reload. `author`
296
+ // rides here for the same reason it rides the queued list (spec 2026-09-26):
297
+ // a transcript-sourced session's durable row carries no user beyond the
298
+ // runner's own login, so this is canopy's only chance to say who typed it.
299
+ // `client_id` is likewise the only way an HTTP send's OPTIMISTIC row (built
300
+ // locally by `noteLocalSend`, never a real server frame) can carry the same
301
+ // id its REST body sent — without it `QueuedRows.hideClientIds` cannot match
302
+ // this row to the server's queued-turn projection, and a contact's or
303
+ // widget's own send rendered twice: once as their bubble, once as a "queued"
304
+ // placeholder for the same send.
305
+ | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string; author?: MessageAuthor | null; client_id?: string } }
243
306
  | { event: "chat.delta"; data: { message_id: string; text: string } }
244
307
  // `turn_index` is the row's transcript ordinal — the same key the persisted
245
308
  // Message carries, so a live tool row sorts into exactly the position it will
@@ -250,7 +313,13 @@ export type WsEvent =
250
313
  | { event: "chat.stream_error"; data: { message_id: string; detail: string } }
251
314
  | { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
252
315
  | { event: "draft.updated"; data: Draft }
253
- | { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
316
+ // Someone else's box, live — keyed by author, not draft id, because a peer's
317
+ // draft is never addressable by this client and never needs to be: only
318
+ // "what are they typing right now" does. An empty `body` means they sent,
319
+ // discarded, or cleared it. Replaces `draft.lock_changed`, which existed
320
+ // only because a session had one shared next-draft to fight over; now
321
+ // everyone has their own, so there is no lock left to report.
322
+ | { event: "draft.typing"; data: PeerDraft }
254
323
  // `client_id` is the sender's receipt for a send that carried one (see
255
324
  // useSessionSocket.sendChat); absent from older servers.
256
325
  | { event: "draft.committed"; data: { draft_id: string; user_message_id: string; client_id?: string } }
@@ -270,4 +339,7 @@ export type WsEvent =
270
339
  display_name?: string;
271
340
  };
272
341
  }
273
- | { event: "presence.left"; data: { user_id: number } };
342
+ | { event: "presence.left"; data: { user_id: number } }
343
+ // The whole queue, wholesale, like `session.turn_status` — a client that
344
+ // just connected has no correct prior to merge a delta into.
345
+ | { event: "session.queued"; data: { queued: QueuedMessage[] } };
@@ -727,3 +727,124 @@ describe("session.stop — whether the stop actually landed", () => {
727
727
  expect(out.stopState).toBeUndefined();
728
728
  });
729
729
  });
730
+
731
+ describe("per-person drafts", () => {
732
+ it("draft.typing upserts a peer by author and an empty body removes it", () => {
733
+ let s = sessionReducer(makeState(), {
734
+ event: "draft.typing",
735
+ data: { author: { id: 2, name: "Bo" }, body: "hi", at: null },
736
+ } as WsEvent);
737
+ s = sessionReducer(s, {
738
+ event: "draft.typing",
739
+ data: { author: { id: 2, name: "Bo" }, body: "hi there", at: null },
740
+ } as WsEvent);
741
+ expect(s.peer_drafts).toEqual([{ author: { id: 2, name: "Bo" }, body: "hi there", at: null }]);
742
+ s = sessionReducer(s, {
743
+ event: "draft.typing",
744
+ data: { author: { id: 2, name: "Bo" }, body: "", at: null },
745
+ } as WsEvent);
746
+ expect(s.peer_drafts).toEqual([]);
747
+ });
748
+
749
+ it("presence.left drops that person's typing row", () => {
750
+ let s = sessionReducer(makeState(), {
751
+ event: "draft.typing",
752
+ data: { author: { id: 2, name: "Bo" }, body: "x", at: null },
753
+ } as WsEvent);
754
+ s = sessionReducer(s, { event: "presence.left", data: { user_id: 2 } } as WsEvent);
755
+ expect(s.peer_drafts).toEqual([]);
756
+ });
757
+
758
+ it("session.queued replaces the whole list", () => {
759
+ const q = {
760
+ turn_id: "t1",
761
+ client_id: "c1",
762
+ author: { name: "Bo", user_id: 2 },
763
+ text: "yo",
764
+ sent_at: "2026-09-26T00:00:00Z",
765
+ state: "queued" as const,
766
+ };
767
+ let s = sessionReducer(makeState(), {
768
+ event: "session.queued",
769
+ data: { queued: [q] },
770
+ } as WsEvent);
771
+ expect(s.queued).toEqual([q]);
772
+ s = sessionReducer(s, { event: "session.queued", data: { queued: [] } } as WsEvent);
773
+ expect(s.queued).toEqual([]);
774
+ });
775
+
776
+ it("chat.user_message keeps the author", () => {
777
+ const s = sessionReducer(makeState(), {
778
+ event: "chat.user_message",
779
+ data: { message_id: "m1", turn_index: 5, plaintext: "hi", author: { name: "Bo", user_id: 2 } },
780
+ } as WsEvent);
781
+ expect(s.messages.at(-1)?.author).toEqual({ name: "Bo", user_id: 2 });
782
+ });
783
+ });
784
+
785
+ describe("sessionReducer — two people saying the same thing (final review I5)", () => {
786
+ const yes = (over: Partial<Message>) =>
787
+ makeMessage({ role: "user", plaintext: "yes", status: "complete", ...over })
788
+
789
+ it("two confirmed 'yes' rows from different authors stay two", () => {
790
+ const seeded = makeState({
791
+ messages: [yes({ id: "a1", turn_index: 10, author: { name: "Alice", user_id: 1 } })],
792
+ })
793
+ const next = sessionReducer(seeded, {
794
+ event: "chat.user_message",
795
+ data: { message_id: "b1", turn_index: 20, plaintext: "yes", author: { name: "Bo", user_id: 2 } },
796
+ } as WsEvent)
797
+ expect(next.messages).toHaveLength(2)
798
+ expect(next.messages.map((m) => m.author?.name)).toEqual(["Alice", "Bo"])
799
+ })
800
+
801
+ it("a confirmed unauthored row is not adopted by someone else's line", () => {
802
+ const seeded = makeState({ messages: [yes({ id: "a1", turn_index: 10 })] })
803
+ const next = sessionReducer(seeded, {
804
+ event: "chat.user_message",
805
+ data: { message_id: "b1", turn_index: 20, plaintext: "yes", author: { name: "Bo", user_id: 2 } },
806
+ } as WsEvent)
807
+ expect(next.messages).toHaveLength(2)
808
+ })
809
+
810
+ it("an optimistic pending 'yes' still reconciles with its echo", () => {
811
+ const seeded = makeState({
812
+ current_user_id: 1,
813
+ messages: [yes({ id: "local:c1", turn_index: 3, status: "pending", content: { client_id: "c1" } })],
814
+ })
815
+ const next = sessionReducer(seeded, {
816
+ event: "chat.user_message",
817
+ data: { message_id: "901", turn_index: 144448, plaintext: "yes", author: { name: "Alice", user_id: 1 } },
818
+ } as WsEvent)
819
+ expect(next.messages).toHaveLength(1)
820
+ expect(next.messages[0].id).toBe("901")
821
+ expect(next.messages[0].status).toBe("complete")
822
+ })
823
+
824
+ it("a receipted send (transient id) still reconciles with its transcript echo", () => {
825
+ // A transcript-sourced send writes no durable row; its receipt carries a
826
+ // `transient:` id, so the row is still waiting for the line the agent read.
827
+ const seeded = makeState({
828
+ current_user_id: 1,
829
+ messages: [yes({ id: "transient:abc", turn_index: 3, content: { client_id: "c1" } })],
830
+ })
831
+ const next = sessionReducer(seeded, {
832
+ event: "chat.user_message",
833
+ data: { message_id: "901", turn_index: 144448, plaintext: "yes", author: { name: "Alice", user_id: 1 } },
834
+ } as WsEvent)
835
+ expect(next.messages).toHaveLength(1)
836
+ })
837
+
838
+ it("my pending 'yes' is not taken by a teammate's 'yes'", () => {
839
+ const seeded = makeState({
840
+ current_user_id: 1,
841
+ messages: [yes({ id: "local:c1", turn_index: 3, status: "pending", content: { client_id: "c1" } })],
842
+ })
843
+ const next = sessionReducer(seeded, {
844
+ event: "chat.user_message",
845
+ data: { message_id: "b1", turn_index: 20, plaintext: "yes", author: { name: "Bo", user_id: 2 } },
846
+ } as WsEvent)
847
+ expect(next.messages).toHaveLength(2)
848
+ expect(next.messages[0].id).toBe("local:c1")
849
+ })
850
+ })