canopy-ui 0.4.0 → 0.6.1
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 +5 -2
- package/src/chat/ChatPanel.tsx +18 -3
- package/src/chat/MessageList.tsx +54 -4
- package/src/chat/SendBox.test.tsx +359 -0
- package/src/chat/SendBox.tsx +183 -21
- package/src/chat/drafts.test.ts +25 -1
- package/src/chat/drafts.ts +18 -0
- package/src/chat/groupToolRuns.test.ts +81 -0
- package/src/chat/groupToolRuns.ts +82 -0
- package/src/chat/index.ts +1 -1
- package/src/chat/pairToolMessages.test.ts +152 -0
- package/src/chat/pairToolMessages.ts +90 -14
- package/src/chat/protocol.ts +15 -2
- package/src/chat/sessionReducer.test.ts +259 -2
- package/src/chat/sessionReducer.ts +127 -4
- package/src/chat/useSessionSocket.ts +77 -14
- package/src/presence/PresenceBadge.test.tsx +84 -0
- package/src/presence/PresenceBadge.tsx +111 -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 +199 -0
- package/src/presence/usePresence.ts +188 -0
|
@@ -118,6 +118,110 @@ describe("pairToolMessages", () => {
|
|
|
118
118
|
);
|
|
119
119
|
expect(pending).toHaveLength(2);
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
// The case this feature exists for: two tool calls in flight at once (Claude
|
|
123
|
+
// does this routinely). Their tool_start events land back-to-back, both
|
|
124
|
+
// still open, before either tool_end arrives — a flat order-based pairing
|
|
125
|
+
// would have no way to tell which result belongs to which call. The
|
|
126
|
+
// results also arrive OUT OF ORDER (second call finishes first), which
|
|
127
|
+
// order-based pairing would get flatly wrong.
|
|
128
|
+
it("pairs overlapping (parallel) tool calls by id, including out-of-order results", () => {
|
|
129
|
+
const rows = pairToolMessages([
|
|
130
|
+
msg({
|
|
131
|
+
id: "1", role: "tool_use",
|
|
132
|
+
content: { id: "call-a", name: "Bash", input: { command: "sleep 10" } },
|
|
133
|
+
}),
|
|
134
|
+
msg({
|
|
135
|
+
id: "2", role: "tool_use",
|
|
136
|
+
content: { id: "call-b", name: "Read", input: { file_path: "/x" } },
|
|
137
|
+
}),
|
|
138
|
+
// call-b finishes first even though call-a started first.
|
|
139
|
+
msg({ id: "3", role: "tool_result", content: { tool_use_id: "call-b" }, plaintext: "file contents" }),
|
|
140
|
+
msg({ id: "4", role: "tool_result", content: { tool_use_id: "call-a" }, plaintext: "done sleeping" }),
|
|
141
|
+
]);
|
|
142
|
+
expect(rows).toHaveLength(2);
|
|
143
|
+
expect(rows[0].kind).toBe("tool_pair");
|
|
144
|
+
expect(rows[1].kind).toBe("tool_pair");
|
|
145
|
+
if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
|
|
146
|
+
expect(rows[0].use.id).toBe("1");
|
|
147
|
+
expect(rows[0].result?.id).toBe("4"); // call-a's result, not the earlier-arriving one
|
|
148
|
+
expect(rows[1].use.id).toBe("2");
|
|
149
|
+
expect(rows[1].result?.id).toBe("3"); // call-b's result
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Two overlapping calls to the SAME tool name — order/name matching alone
|
|
154
|
+
// is ambiguous here (that's the whole point of the id); only tool_use_id
|
|
155
|
+
// disambiguates which result goes with which call.
|
|
156
|
+
it("pairs two overlapping calls with the same tool name by id", () => {
|
|
157
|
+
const rows = pairToolMessages([
|
|
158
|
+
msg({
|
|
159
|
+
id: "1", role: "tool_use",
|
|
160
|
+
content: { id: "call-1", name: "Bash", input: { command: "echo one" } },
|
|
161
|
+
}),
|
|
162
|
+
msg({
|
|
163
|
+
id: "2", role: "tool_use",
|
|
164
|
+
content: { id: "call-2", name: "Bash", input: { command: "echo two" } },
|
|
165
|
+
}),
|
|
166
|
+
msg({ id: "3", role: "tool_result", content: { tool_use_id: "call-2" }, plaintext: "two" }),
|
|
167
|
+
msg({ id: "4", role: "tool_result", content: { tool_use_id: "call-1" }, plaintext: "one" }),
|
|
168
|
+
]);
|
|
169
|
+
expect(rows).toHaveLength(2);
|
|
170
|
+
if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
|
|
171
|
+
expect(rows[0].use.id).toBe("1");
|
|
172
|
+
expect(rows[0].result?.id).toBe("4");
|
|
173
|
+
expect(rows[0].result?.plaintext).toBe("one");
|
|
174
|
+
expect(rows[1].use.id).toBe("2");
|
|
175
|
+
expect(rows[1].result?.id).toBe("3");
|
|
176
|
+
expect(rows[1].result?.plaintext).toBe("two");
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Backward compatibility: events with no correlation id at all (older
|
|
181
|
+
// ledger rows, or a runner that hasn't been updated yet) must still pair —
|
|
182
|
+
// falling back to the pre-id FIFO order heuristic.
|
|
183
|
+
it("falls back to FIFO order pairing when neither side carries an id", () => {
|
|
184
|
+
const rows = pairToolMessages([
|
|
185
|
+
msg({ id: "1", role: "tool_use", content: { name: "Bash" } }),
|
|
186
|
+
msg({ id: "2", role: "tool_use", content: { name: "Read" } }),
|
|
187
|
+
msg({ id: "3", role: "tool_result", plaintext: "first result" }),
|
|
188
|
+
msg({ id: "4", role: "tool_result", plaintext: "second result" }),
|
|
189
|
+
]);
|
|
190
|
+
expect(rows).toHaveLength(2);
|
|
191
|
+
if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
|
|
192
|
+
expect(rows[0].use.id).toBe("1");
|
|
193
|
+
expect(rows[0].result?.id).toBe("3");
|
|
194
|
+
expect(rows[1].use.id).toBe("2");
|
|
195
|
+
expect(rows[1].result?.id).toBe("4");
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// The real production shape: a session whose earlier turns predate the
|
|
200
|
+
// tool_use_id field (no ids) followed by a later turn from an updated
|
|
201
|
+
// producer (has ids) — both must pair correctly, and the id-less fallback
|
|
202
|
+
// queue must not accidentally absorb an id-tagged result or vice versa.
|
|
203
|
+
it("pairs correctly across a mixed stream (legacy no-id turn followed by an id-tagged turn)", () => {
|
|
204
|
+
const rows = pairToolMessages([
|
|
205
|
+
// Legacy turn: no ids at all.
|
|
206
|
+
msg({ id: "1", role: "tool_use", content: { name: "Bash" } }),
|
|
207
|
+
msg({ id: "2", role: "tool_result", plaintext: "legacy result" }),
|
|
208
|
+
// Newer turn: two parallel calls, correlated by id.
|
|
209
|
+
msg({ id: "3", role: "tool_use", content: { id: "new-a", name: "Bash" } }),
|
|
210
|
+
msg({ id: "4", role: "tool_use", content: { id: "new-b", name: "Bash" } }),
|
|
211
|
+
msg({ id: "5", role: "tool_result", content: { tool_use_id: "new-b" }, plaintext: "b done" }),
|
|
212
|
+
msg({ id: "6", role: "tool_result", content: { tool_use_id: "new-a" }, plaintext: "a done" }),
|
|
213
|
+
]);
|
|
214
|
+
expect(rows).toHaveLength(3);
|
|
215
|
+
expect(rows.every((r) => r.kind === "tool_pair")).toBe(true);
|
|
216
|
+
if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair" && rows[2].kind === "tool_pair") {
|
|
217
|
+
expect(rows[0].use.id).toBe("1");
|
|
218
|
+
expect(rows[0].result?.id).toBe("2");
|
|
219
|
+
expect(rows[1].use.id).toBe("3");
|
|
220
|
+
expect(rows[1].result?.id).toBe("6"); // new-a's result
|
|
221
|
+
expect(rows[2].use.id).toBe("4");
|
|
222
|
+
expect(rows[2].result?.id).toBe("5"); // new-b's result
|
|
223
|
+
}
|
|
224
|
+
});
|
|
121
225
|
});
|
|
122
226
|
|
|
123
227
|
describe("deriveToolStatus", () => {
|
|
@@ -206,3 +310,51 @@ describe("toolPreview / toolDisplayName", () => {
|
|
|
206
310
|
expect(toolDisplayName(use)).toBe("drive_create_file");
|
|
207
311
|
});
|
|
208
312
|
});
|
|
313
|
+
|
|
314
|
+
describe("stale pending rows", () => {
|
|
315
|
+
const pendingUse = (id: string): Message => ({
|
|
316
|
+
id: `live-${id}`, turn_index: -1, role: "tool_use",
|
|
317
|
+
content: { id, name: "Bash", status: "pending" }, plaintext: "",
|
|
318
|
+
status: "complete", error_detail: null, started_at: null, completed_at: null,
|
|
319
|
+
created_at: "",
|
|
320
|
+
})
|
|
321
|
+
const text = (id: string): Message => ({
|
|
322
|
+
id, turn_index: 10, role: "assistant", content: {}, plaintext: "done",
|
|
323
|
+
status: "complete", error_detail: null, started_at: null, completed_at: null,
|
|
324
|
+
created_at: "",
|
|
325
|
+
})
|
|
326
|
+
const resultFor = (id: string): Message => ({
|
|
327
|
+
id: `r-${id}`, turn_index: 20, role: "tool_result",
|
|
328
|
+
content: { tool_use_id: id }, plaintext: "out",
|
|
329
|
+
status: "complete", error_detail: null, started_at: null, completed_at: null,
|
|
330
|
+
created_at: "",
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it("keeps a pending row while it is the newest thing — it IS running", () => {
|
|
334
|
+
const rows = pairToolMessages([text("a"), pendingUse("t1")])
|
|
335
|
+
expect(rows).toHaveLength(2)
|
|
336
|
+
expect(rows[1]).toMatchObject({ kind: "tool_pair", result: null })
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it("drops a pending row once later activity proves it is not in flight", () => {
|
|
340
|
+
// The live bug: PreToolUse fired, its PostToolUse never arrived, and the row
|
|
341
|
+
// rendered "running…" forever — while a reload showed nothing, because the
|
|
342
|
+
// durable transcript never had it.
|
|
343
|
+
const rows = pairToolMessages([pendingUse("t1"), text("a")])
|
|
344
|
+
expect(rows).toHaveLength(1)
|
|
345
|
+
expect(rows[0].kind).toBe("message")
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it("keeps a pending row whose result DID arrive, so the pair renders", () => {
|
|
349
|
+
const rows = pairToolMessages([pendingUse("t1"), resultFor("t1"), text("a")])
|
|
350
|
+
const pair = rows.find((r) => r.kind === "tool_pair")
|
|
351
|
+
expect(pair).toBeDefined()
|
|
352
|
+
expect((pair as { result: Message | null }).result).not.toBeNull()
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it("leaves ordinary completed tool rows untouched", () => {
|
|
356
|
+
const done: Message = { ...pendingUse("t9"), content: { id: "t9", name: "Bash" } }
|
|
357
|
+
const rows = pairToolMessages([done, resultFor("t9"), text("a")])
|
|
358
|
+
expect(rows.filter((r) => r.kind === "tool_pair")).toHaveLength(1)
|
|
359
|
+
})
|
|
360
|
+
})
|
|
@@ -4,15 +4,27 @@ import type { Message } from "./protocol";
|
|
|
4
4
|
* A renderable row in the chat: either a single message (user / assistant /
|
|
5
5
|
* standalone tool, etc.) or a paired tool_use + tool_result.
|
|
6
6
|
*
|
|
7
|
-
* Pairing rule: a ``tool_use`` row is
|
|
8
|
-
* ``tool_result`` whose ``content.tool_use_id``
|
|
9
|
-
* ``content.id
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* Pairing rule, id-first with an order-based fallback: a ``tool_use`` row is
|
|
8
|
+
* paired with the FIRST subsequent ``tool_result`` whose ``content.tool_use_id``
|
|
9
|
+
* matches the ``tool_use``'s ``content.id``, when both carry one. Correlation
|
|
10
|
+
* ids are what make this unambiguous — with **parallel** tool calls (routine
|
|
11
|
+
* for Claude) or two calls sharing a name, a flat tool_start/tool_end stream
|
|
12
|
+
* has no other way to tell which result belongs to which call.
|
|
12
13
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Not every producer stamps ids yet: events already in the ledger predate
|
|
15
|
+
* this field, and some runners (see `packages/canopy_runner`) don't emit it
|
|
16
|
+
* until updated separately. When a ``tool_result`` carries no id at all, it
|
|
17
|
+
* falls back to the FIRST still-open ``tool_use`` that *also* has no id
|
|
18
|
+
* (oldest-first, FIFO) — today's pre-correlation pairing heuristic. That
|
|
19
|
+
* fallback queue is tracked separately from the id map, so an id-tagged
|
|
20
|
+
* stream and a legacy no-id stream can be interleaved (e.g. older turns in
|
|
21
|
+
* the same session predating the id, followed by newer ones that have it)
|
|
22
|
+
* without cross-pairing into each other.
|
|
23
|
+
*
|
|
24
|
+
* Unpaired ``tool_result`` rows (an id that matches no pending ``tool_use``,
|
|
25
|
+
* or no id and nothing left in the no-id fallback queue — shouldn't happen
|
|
26
|
+
* in practice, but defend) fall through as standalone messages so we never
|
|
27
|
+
* silently drop content.
|
|
16
28
|
*/
|
|
17
29
|
export type ChatRow =
|
|
18
30
|
| { kind: "message"; message: Message; key: string }
|
|
@@ -21,20 +33,61 @@ export type ChatRow =
|
|
|
21
33
|
function toolUseId(message: Message): string | null {
|
|
22
34
|
const content = message.content as Record<string, unknown> | undefined;
|
|
23
35
|
const id = content?.id;
|
|
24
|
-
return typeof id === "string" ? id : null;
|
|
36
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
25
37
|
}
|
|
26
38
|
|
|
27
39
|
function toolResultId(message: Message): string | null {
|
|
28
40
|
const content = message.content as Record<string, unknown> | undefined;
|
|
29
41
|
const id = content?.tool_use_id;
|
|
30
|
-
return typeof id === "string" ? id : null;
|
|
42
|
+
return typeof id === "string" && id !== "" ? id : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A pending live row (`status: "pending"` from PreToolUse, no ordinal) is a
|
|
47
|
+
* PLACEHOLDER. It is meant to be replaced when its result arrives — but if that
|
|
48
|
+
* never lands (the turn ended, the runner stopped streaming, a hook was dropped),
|
|
49
|
+
* it strands a row rendering "running…" forever, which reads as an agent stuck
|
|
50
|
+
* mid-call. Observed live 2026-07-27: one orphan row per turn, and it vanished on
|
|
51
|
+
* reload because the durable transcript never contained it.
|
|
52
|
+
*
|
|
53
|
+
* A pending row is therefore dropped once ANY later row exists — later activity
|
|
54
|
+
* is proof that whatever it was waiting on is no longer in flight. It survives
|
|
55
|
+
* only while it is genuinely the newest thing in the session, which is exactly
|
|
56
|
+
* when "running…" is true.
|
|
57
|
+
*/
|
|
58
|
+
function dropStaleLiveRows(messages: Message[]): Message[] {
|
|
59
|
+
const lastIndex = messages.length - 1;
|
|
60
|
+
return messages.filter((m, i) => {
|
|
61
|
+
if (m.role !== "tool_use") return true;
|
|
62
|
+
const content = m.content as Record<string, unknown> | undefined;
|
|
63
|
+
if (content?.status !== "pending") return true;
|
|
64
|
+
const id = typeof content.id === "string" ? content.id : null;
|
|
65
|
+
// Its result arrived — the pair will render normally, keep it.
|
|
66
|
+
if (
|
|
67
|
+
id &&
|
|
68
|
+
messages.some(
|
|
69
|
+
(o) =>
|
|
70
|
+
o.role === "tool_result" &&
|
|
71
|
+
(o.content as Record<string, unknown> | undefined)?.tool_use_id === id,
|
|
72
|
+
)
|
|
73
|
+
) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
// Still the newest row: genuinely in flight.
|
|
77
|
+
return i === lastIndex;
|
|
78
|
+
});
|
|
31
79
|
}
|
|
32
80
|
|
|
33
81
|
export function pairToolMessages(messages: Message[]): ChatRow[] {
|
|
82
|
+
messages = dropStaleLiveRows(messages);
|
|
34
83
|
const rows: ChatRow[] = [];
|
|
35
84
|
// Map of tool_use_id → index into rows[] for that pair. Lets a tool_result
|
|
36
85
|
// arriving later in the stream slot itself into the existing pair row.
|
|
37
86
|
const pendingByToolId = new Map<string, number>();
|
|
87
|
+
// FIFO fallback queue of row indices for tool_use rows with NO id — the
|
|
88
|
+
// pre-correlation pairing heuristic, kept alive for producers/ledger rows
|
|
89
|
+
// that predate tool_use_id.
|
|
90
|
+
const pendingNoId: number[] = [];
|
|
38
91
|
|
|
39
92
|
for (const m of messages) {
|
|
40
93
|
if (m.role === "tool_use") {
|
|
@@ -46,8 +99,11 @@ export function pairToolMessages(messages: Message[]): ChatRow[] {
|
|
|
46
99
|
key: `pair-${m.id}`,
|
|
47
100
|
};
|
|
48
101
|
rows.push(row);
|
|
102
|
+
const idx = rows.length - 1;
|
|
49
103
|
if (id !== null) {
|
|
50
|
-
pendingByToolId.set(id,
|
|
104
|
+
pendingByToolId.set(id, idx);
|
|
105
|
+
} else {
|
|
106
|
+
pendingNoId.push(idx);
|
|
51
107
|
}
|
|
52
108
|
continue;
|
|
53
109
|
}
|
|
@@ -63,10 +119,30 @@ export function pairToolMessages(messages: Message[]): ChatRow[] {
|
|
|
63
119
|
continue;
|
|
64
120
|
}
|
|
65
121
|
}
|
|
122
|
+
// An id that matches no pending id-tagged use — a genuine ghost,
|
|
123
|
+
// never absorbed by the no-id fallback queue (that queue is for
|
|
124
|
+
// results that carry no id of their own, not for stray ids).
|
|
125
|
+
rows.push({ kind: "message", message: m, key: `msg-${m.id}` });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// No id on the result at all — fall back to the oldest still-open
|
|
129
|
+
// no-id tool_use (order-based pairing, same as before correlation
|
|
130
|
+
// ids existed).
|
|
131
|
+
let paired = false;
|
|
132
|
+
while (pendingNoId.length > 0) {
|
|
133
|
+
const idx = pendingNoId.shift() as number;
|
|
134
|
+
const row = rows[idx];
|
|
135
|
+
if (row.kind === "tool_pair" && row.result === null) {
|
|
136
|
+
rows[idx] = { ...row, result: m };
|
|
137
|
+
paired = true;
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!paired) {
|
|
142
|
+
// Nothing left in the fallback queue to pair with — standalone so
|
|
143
|
+
// content isn't silently dropped.
|
|
144
|
+
rows.push({ kind: "message", message: m, key: `msg-${m.id}` });
|
|
66
145
|
}
|
|
67
|
-
// Fallthrough: no preceding tool_use match — show standalone so
|
|
68
|
-
// content isn't silently dropped.
|
|
69
|
-
rows.push({ kind: "message", message: m, key: `msg-${m.id}` });
|
|
70
146
|
continue;
|
|
71
147
|
}
|
|
72
148
|
rows.push({ kind: "message", message: m, key: `msg-${m.id}` });
|
package/src/chat/protocol.ts
CHANGED
|
@@ -62,6 +62,10 @@ export interface Participant {
|
|
|
62
62
|
|
|
63
63
|
export interface SessionState {
|
|
64
64
|
messages: Message[];
|
|
65
|
+
/** Live agent activity, from the runner's turn-boundary hooks. Undefined when
|
|
66
|
+
* no hook has reported yet — the caller then falls back to the server's
|
|
67
|
+
* coarser `running` flag. */
|
|
68
|
+
activity?: "working" | "idle";
|
|
65
69
|
active_draft: Draft | null;
|
|
66
70
|
participants: Participant[];
|
|
67
71
|
presence_user_ids: number[];
|
|
@@ -85,9 +89,18 @@ export type WsEvent =
|
|
|
85
89
|
| { event: "session.error"; data: { code: string; message: string; detail?: unknown } }
|
|
86
90
|
| { event: "session.title_updated"; data: { title: string } }
|
|
87
91
|
| { event: "chat.stream_start"; data: { message_id: string; turn_index: number } }
|
|
92
|
+
// The agent started or finished a turn. Distinct from tool events: it fires
|
|
93
|
+
// while Claude is THINKING, before any content exists to show.
|
|
94
|
+
| { event: "session.activity"; data: { state: "working" | "idle" } }
|
|
95
|
+
// A human typed into emdash rather than into this page. No client echoed it,
|
|
96
|
+
// so this is the only way it reaches the browser before a reload.
|
|
97
|
+
| { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
|
|
88
98
|
| { event: "chat.delta"; data: { message_id: string; text: string } }
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
// `turn_index` is the row's transcript ordinal — the same key the persisted
|
|
100
|
+
// Message carries, so a live tool row sorts into exactly the position it will
|
|
101
|
+
// occupy after a reload. Optional: an older server omits it.
|
|
102
|
+
| { event: "chat.tool_use"; data: { parent_message_id: string | null; tool_message_id: string; turn_index?: number; block: Record<string, unknown> } }
|
|
103
|
+
| { event: "chat.tool_result"; data: { parent_message_id: string | null; tool_message_id: string; turn_index?: number; block: Record<string, unknown> } }
|
|
91
104
|
| { event: "chat.stream_complete"; data: { message_id: string; plaintext: string } }
|
|
92
105
|
| { event: "chat.stream_error"; data: { message_id: string; detail: string } }
|
|
93
106
|
| { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
|
|
@@ -122,13 +122,86 @@ describe("sessionReducer — chat stream", () => {
|
|
|
122
122
|
expect(next.messages[0].error_detail).toMatch(/142/)
|
|
123
123
|
})
|
|
124
124
|
|
|
125
|
-
it("chat.tool_use
|
|
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.
|
|
126
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 })] })
|
|
127
200
|
const next = sessionReducer(prev, {
|
|
128
201
|
event: "chat.tool_use",
|
|
129
202
|
data: { parent_message_id: null, tool_message_id: "t1", block: {} },
|
|
130
203
|
} as WsEvent)
|
|
131
|
-
expect(next).toBe(
|
|
204
|
+
expect(next.messages[1].turn_index).toBe(8)
|
|
132
205
|
})
|
|
133
206
|
})
|
|
134
207
|
|
|
@@ -282,3 +355,187 @@ describe("sessionReducer — session.error draft_version_mismatch", () => {
|
|
|
282
355
|
expect(next).toBe(prev)
|
|
283
356
|
})
|
|
284
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
|
+
})
|