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,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import type { Message } from "./protocol"
|
|
3
|
+
import { prependHistory } from "./history"
|
|
4
|
+
|
|
5
|
+
function msg(turn_index: number, plaintext = `m${turn_index}`): Message {
|
|
6
|
+
return {
|
|
7
|
+
id: `t${turn_index}`, turn_index, role: "user", content: {}, plaintext,
|
|
8
|
+
status: "complete", error_detail: null, started_at: null, completed_at: null,
|
|
9
|
+
created_at: "",
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("prependHistory", () => {
|
|
14
|
+
it("prepends older messages ahead of current, chronological", () => {
|
|
15
|
+
const current = [msg(30), msg(31)]
|
|
16
|
+
const older = [msg(28), msg(29)]
|
|
17
|
+
expect(prependHistory(current, older).map((m) => m.turn_index)).toEqual([28, 29, 30, 31])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("dedupes on turn_index (an overlapping page is not double-inserted)", () => {
|
|
21
|
+
const current = [msg(30), msg(31)]
|
|
22
|
+
const older = [msg(29), msg(30)] // 30 overlaps
|
|
23
|
+
expect(prependHistory(current, older).map((m) => m.turn_index)).toEqual([29, 30, 31])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("returns the same reference when older is empty", () => {
|
|
27
|
+
const current = [msg(30)]
|
|
28
|
+
expect(prependHistory(current, [])).toBe(current)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("returns the same reference when every older row already exists", () => {
|
|
32
|
+
const current = [msg(30), msg(31)]
|
|
33
|
+
expect(prependHistory(current, [msg(30)])).toBe(current)
|
|
34
|
+
})
|
|
35
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Message } from "./protocol";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Merge an older page (from the REST scroll-back endpoint) into the current
|
|
5
|
+
* transcript: prepend, dedupe by turn_index, keep chronological. Pure — no React,
|
|
6
|
+
* no WebSocket — so the container can apply "Load earlier" to the socket's
|
|
7
|
+
* SessionState without a new WS frame. Returns `current` unchanged (same
|
|
8
|
+
* reference) when nothing new prepends, so callers can skip a re-render.
|
|
9
|
+
*/
|
|
10
|
+
export function prependHistory(current: Message[], older: Message[]): Message[] {
|
|
11
|
+
if (older.length === 0) return current;
|
|
12
|
+
const seen = new Set(current.map((m) => m.turn_index));
|
|
13
|
+
const fresh = older.filter((m) => !seen.has(m.turn_index));
|
|
14
|
+
if (fresh.length === 0) return current;
|
|
15
|
+
return [...fresh, ...current].sort((a, b) => a.turn_index - b.turn_index);
|
|
16
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// canopy-ui/chat — a reusable, app-agnostic multiplayer chat kit.
|
|
2
|
+
//
|
|
3
|
+
// Speaks the canonical chat WebSocket protocol (ace-web's contract; string
|
|
4
|
+
// ids). App specifics — the ws URL builder, the markdown renderer, session
|
|
5
|
+
// meta — are injected. The presentational tree is props-in / callbacks-out.
|
|
6
|
+
|
|
7
|
+
// Protocol types
|
|
8
|
+
export type {
|
|
9
|
+
Message,
|
|
10
|
+
MessageRole,
|
|
11
|
+
MessageStatus,
|
|
12
|
+
Draft,
|
|
13
|
+
Participant,
|
|
14
|
+
SessionState,
|
|
15
|
+
WsAction,
|
|
16
|
+
WsEvent,
|
|
17
|
+
} from "./protocol";
|
|
18
|
+
|
|
19
|
+
// Reducer (pure)
|
|
20
|
+
export { sessionReducer } from "./sessionReducer";
|
|
21
|
+
export { prependHistory } from "./history";
|
|
22
|
+
|
|
23
|
+
// Hooks
|
|
24
|
+
export {
|
|
25
|
+
useSessionSocket,
|
|
26
|
+
type UseSessionSocketOptions,
|
|
27
|
+
type UseSessionSocketResult,
|
|
28
|
+
} from "./useSessionSocket";
|
|
29
|
+
export { useStickyBottom } from "./useStickyBottom";
|
|
30
|
+
|
|
31
|
+
// Draft idle helpers
|
|
32
|
+
export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
|
|
33
|
+
|
|
34
|
+
// Tool-message pairing helpers
|
|
35
|
+
export {
|
|
36
|
+
pairToolMessages,
|
|
37
|
+
deriveToolStatus,
|
|
38
|
+
toolPreview,
|
|
39
|
+
toolDisplayName,
|
|
40
|
+
type ChatRow,
|
|
41
|
+
type ToolCallStatus,
|
|
42
|
+
} from "./pairToolMessages";
|
|
43
|
+
|
|
44
|
+
// Presentational components
|
|
45
|
+
export { ChatPanel, type ChatPanelProps } from "./ChatPanel";
|
|
46
|
+
export { MessageList } from "./MessageList";
|
|
47
|
+
export { MessageItem, type RenderMarkdown } from "./MessageItem";
|
|
48
|
+
export { ToolCallPair } from "./ToolCallPair";
|
|
49
|
+
export { SendBox, type PendingAttachment } from "./SendBox";
|
|
50
|
+
export { PresenceChips } from "./PresenceChips";
|
|
51
|
+
export { ConnectionStatus } from "./ConnectionStatus";
|
|
52
|
+
export {
|
|
53
|
+
PlacementBanner,
|
|
54
|
+
type PlacementBannerProps,
|
|
55
|
+
type PlacementRunner,
|
|
56
|
+
} from "./PlacementBanner";
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { Message } from "./protocol";
|
|
4
|
+
import {
|
|
5
|
+
deriveToolStatus,
|
|
6
|
+
pairToolMessages,
|
|
7
|
+
toolDisplayName,
|
|
8
|
+
toolPreview,
|
|
9
|
+
} from "./pairToolMessages";
|
|
10
|
+
|
|
11
|
+
let seq = 0;
|
|
12
|
+
function msg(
|
|
13
|
+
partial: Partial<Message> & { id: string; role: Message["role"] },
|
|
14
|
+
): Message {
|
|
15
|
+
return {
|
|
16
|
+
turn_index: seq++,
|
|
17
|
+
content: {},
|
|
18
|
+
plaintext: "",
|
|
19
|
+
status: "complete",
|
|
20
|
+
error_detail: null,
|
|
21
|
+
started_at: null,
|
|
22
|
+
completed_at: null,
|
|
23
|
+
created_at: "2026-05-13T00:00:00Z",
|
|
24
|
+
...partial,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("pairToolMessages", () => {
|
|
29
|
+
it("pairs consecutive tool_use + tool_result by id/tool_use_id", () => {
|
|
30
|
+
const rows = pairToolMessages([
|
|
31
|
+
msg({ id: "1", role: "user", plaintext: "hi" }),
|
|
32
|
+
msg({
|
|
33
|
+
id: "2",
|
|
34
|
+
role: "tool_use",
|
|
35
|
+
content: { id: "t1", name: "Bash", input: { command: "ls" } },
|
|
36
|
+
}),
|
|
37
|
+
msg({
|
|
38
|
+
id: "3",
|
|
39
|
+
role: "tool_result",
|
|
40
|
+
content: { tool_use_id: "t1" },
|
|
41
|
+
plaintext: "out",
|
|
42
|
+
}),
|
|
43
|
+
msg({ id: "4", role: "assistant", plaintext: "done" }),
|
|
44
|
+
]);
|
|
45
|
+
expect(rows).toHaveLength(3);
|
|
46
|
+
expect(rows[0].kind).toBe("message");
|
|
47
|
+
expect(rows[1].kind).toBe("tool_pair");
|
|
48
|
+
if (rows[1].kind === "tool_pair") {
|
|
49
|
+
expect(rows[1].use.id).toBe("2");
|
|
50
|
+
expect(rows[1].result?.id).toBe("3");
|
|
51
|
+
}
|
|
52
|
+
expect(rows[2].kind).toBe("message");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("pairs tool_use with a later non-adjacent tool_result", () => {
|
|
56
|
+
const rows = pairToolMessages([
|
|
57
|
+
msg({ id: "1", role: "tool_use", content: { id: "a", name: "X" } }),
|
|
58
|
+
msg({ id: "2", role: "tool_use", content: { id: "b", name: "Y" } }),
|
|
59
|
+
msg({ id: "3", role: "tool_result", content: { tool_use_id: "a" } }),
|
|
60
|
+
msg({ id: "4", role: "tool_result", content: { tool_use_id: "b" } }),
|
|
61
|
+
]);
|
|
62
|
+
expect(rows).toHaveLength(2);
|
|
63
|
+
expect(rows[0].kind).toBe("tool_pair");
|
|
64
|
+
expect(rows[1].kind).toBe("tool_pair");
|
|
65
|
+
if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
|
|
66
|
+
expect(rows[0].use.id).toBe("1");
|
|
67
|
+
expect(rows[0].result?.id).toBe("3");
|
|
68
|
+
expect(rows[1].use.id).toBe("2");
|
|
69
|
+
expect(rows[1].result?.id).toBe("4");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("leaves an in-flight tool_use with null result so UI can show pending", () => {
|
|
74
|
+
const rows = pairToolMessages([
|
|
75
|
+
msg({ id: "1", role: "tool_use", content: { id: "t1", name: "Bash" } }),
|
|
76
|
+
]);
|
|
77
|
+
expect(rows).toHaveLength(1);
|
|
78
|
+
expect(rows[0].kind).toBe("tool_pair");
|
|
79
|
+
if (rows[0].kind === "tool_pair") {
|
|
80
|
+
expect(rows[0].result).toBeNull();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("falls back to standalone when a tool_result has no matching use", () => {
|
|
85
|
+
const rows = pairToolMessages([
|
|
86
|
+
msg({ id: "1", role: "tool_result", content: { tool_use_id: "ghost" } }),
|
|
87
|
+
]);
|
|
88
|
+
expect(rows).toHaveLength(1);
|
|
89
|
+
expect(rows[0].kind).toBe("message");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("doesn't lose tool calls in long sessions", () => {
|
|
93
|
+
const msgs: Message[] = [];
|
|
94
|
+
for (let i = 0; i < 44; i++) {
|
|
95
|
+
msgs.push(
|
|
96
|
+
msg({
|
|
97
|
+
id: `${i * 2 + 1}`,
|
|
98
|
+
role: "tool_use",
|
|
99
|
+
content: { id: `t${i}`, name: "Bash" },
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
if (i < 42) {
|
|
103
|
+
msgs.push(
|
|
104
|
+
msg({
|
|
105
|
+
id: `${i * 2 + 2}`,
|
|
106
|
+
role: "tool_result",
|
|
107
|
+
content: { tool_use_id: `t${i}` },
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const rows = pairToolMessages(msgs);
|
|
113
|
+
expect(rows).toHaveLength(44);
|
|
114
|
+
expect(rows.filter((r) => r.kind === "tool_pair")).toHaveLength(44);
|
|
115
|
+
// First 42 paired, last 2 pending.
|
|
116
|
+
const pending = rows.filter(
|
|
117
|
+
(r) => r.kind === "tool_pair" && r.result === null,
|
|
118
|
+
);
|
|
119
|
+
expect(pending).toHaveLength(2);
|
|
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
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("deriveToolStatus", () => {
|
|
228
|
+
it("returns pending when result is missing", () => {
|
|
229
|
+
const use = msg({ id: "1", role: "tool_use" });
|
|
230
|
+
expect(deriveToolStatus(use, null).kind).toBe("pending");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("returns error when result is in error status", () => {
|
|
234
|
+
const use = msg({ id: "1", role: "tool_use" });
|
|
235
|
+
const result = msg({ id: "2", role: "tool_result", status: "error" });
|
|
236
|
+
expect(deriveToolStatus(use, result).kind).toBe("error");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("sniffs error-like result content", () => {
|
|
240
|
+
const use = msg({ id: "1", role: "tool_use" });
|
|
241
|
+
const result = msg({
|
|
242
|
+
id: "2",
|
|
243
|
+
role: "tool_result",
|
|
244
|
+
plaintext: "Error: file not found",
|
|
245
|
+
});
|
|
246
|
+
expect(deriveToolStatus(use, result).kind).toBe("error");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("returns success on a clean result", () => {
|
|
250
|
+
const use = msg({ id: "1", role: "tool_use" });
|
|
251
|
+
const result = msg({ id: "2", role: "tool_result", plaintext: "ok" });
|
|
252
|
+
expect(deriveToolStatus(use, result).kind).toBe("success");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe("toolPreview / toolDisplayName", () => {
|
|
257
|
+
it("shows the first line of a Bash command", () => {
|
|
258
|
+
const use = msg({
|
|
259
|
+
id: "1",
|
|
260
|
+
role: "tool_use",
|
|
261
|
+
content: {
|
|
262
|
+
name: "Bash",
|
|
263
|
+
input: { command: "ls -la /tmp\n# more after newline" },
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
expect(toolPreview(use, null)).toBe("ls -la /tmp");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("shows file_path for Read/Write/Edit", () => {
|
|
270
|
+
for (const name of ["Read", "Write", "Edit"]) {
|
|
271
|
+
const use = msg({
|
|
272
|
+
id: "1",
|
|
273
|
+
role: "tool_use",
|
|
274
|
+
content: { name, input: { file_path: "/a/b.txt" } },
|
|
275
|
+
});
|
|
276
|
+
expect(toolPreview(use, null)).toBe("/a/b.txt");
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("shows the todo count for TodoWrite", () => {
|
|
281
|
+
const use = msg({
|
|
282
|
+
id: "1",
|
|
283
|
+
role: "tool_use",
|
|
284
|
+
content: {
|
|
285
|
+
name: "TodoWrite",
|
|
286
|
+
input: { todos: [{}, {}, {}] },
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
expect(toolPreview(use, null)).toBe("3 todos");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("shows the skill name for Skill", () => {
|
|
293
|
+
const use = msg({
|
|
294
|
+
id: "1",
|
|
295
|
+
role: "tool_use",
|
|
296
|
+
content: { name: "Skill", input: { skill: "ace:run" } },
|
|
297
|
+
});
|
|
298
|
+
expect(toolPreview(use, null)).toBe("ace:run");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("strips the mcp prefix for MCP tools", () => {
|
|
302
|
+
const use = msg({
|
|
303
|
+
id: "1",
|
|
304
|
+
role: "tool_use",
|
|
305
|
+
content: {
|
|
306
|
+
name: "mcp__plugin_ace_ace-gdrive__drive_create_file",
|
|
307
|
+
input: {},
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
expect(toolDisplayName(use)).toBe("drive_create_file");
|
|
311
|
+
});
|
|
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
|
+
})
|