@brainpilot/web 0.0.5 → 0.0.6

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.
Files changed (52) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +5 -2
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  8. package/src/__tests__/demoConversation.test.ts +73 -0
  9. package/src/__tests__/demoReset.test.ts +24 -0
  10. package/src/__tests__/runningToast.test.ts +29 -0
  11. package/src/__tests__/tokenUsage.test.ts +48 -0
  12. package/src/__tests__/toolDisplay.test.ts +55 -0
  13. package/src/__tests__/traceReducer.test.ts +62 -0
  14. package/src/components/chat/MessageStream.tsx +97 -56
  15. package/src/components/chat/PromptComposer.tsx +120 -29
  16. package/src/components/chat/chatScrollMemory.ts +49 -0
  17. package/src/components/demo/DemoView.tsx +91 -29
  18. package/src/components/demo/TraceNodeModal.tsx +6 -2
  19. package/src/components/demo/demoBundle.ts +7 -2
  20. package/src/components/demo/demoReset.ts +16 -0
  21. package/src/components/session/AgentNetwork.tsx +68 -75
  22. package/src/components/session/AgentTraceViews.tsx +35 -70
  23. package/src/components/session/AnalyticsTab.tsx +58 -224
  24. package/src/components/session/TraceGraphView.tsx +36 -30
  25. package/src/components/session/TraceNodeDetail.tsx +61 -24
  26. package/src/components/session/agentNetworkShared.ts +10 -0
  27. package/src/components/session/traceLayout.ts +32 -0
  28. package/src/components/settings/SettingsDialog.tsx +19 -1
  29. package/src/components/shell/DesktopShell.tsx +39 -14
  30. package/src/components/sidebar/Sidebar.tsx +6 -2
  31. package/src/contexts/SSEContext.tsx +90 -1
  32. package/src/contexts/SessionContext.tsx +354 -43
  33. package/src/contexts/agentsReducer.ts +49 -0
  34. package/src/contexts/runningToast.ts +33 -0
  35. package/src/contexts/traceReducer.ts +62 -0
  36. package/src/contexts/turnTimer.test.ts +97 -0
  37. package/src/contexts/turnTimer.ts +108 -0
  38. package/src/contexts/useTurnTimer.ts +104 -0
  39. package/src/contracts/backend.ts +53 -2
  40. package/src/i18n/messages/analytics.ts +16 -6
  41. package/src/i18n/messages/chat.ts +26 -4
  42. package/src/i18n/messages/contexts.ts +2 -0
  43. package/src/i18n/messages/network.ts +13 -9
  44. package/src/i18n/messages/profile.ts +4 -0
  45. package/src/i18n/messages/settings.ts +4 -0
  46. package/src/i18n/messages/trace.ts +69 -17
  47. package/src/mocks/backend.ts +7 -0
  48. package/src/styles/global.css +204 -55
  49. package/src/utils/api.ts +105 -8
  50. package/src/utils/toolDisplay.ts +74 -0
  51. package/dist/assets/index-C-8G4D4j.js +0 -448
  52. package/dist/assets/index-C501m5OS.css +0 -1
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <meta name="color-scheme" content="light dark" />
7
7
  <title>BrainPilot</title>
8
- <script type="module" crossorigin src="/assets/index-C-8G4D4j.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-C501m5OS.css">
8
+ <script type="module" crossorigin src="/assets/index-CeUzk-ej.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Br55rkHb.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@brainpilot/web",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
+ "engines": {
5
+ "node": ">=22"
6
+ },
4
7
  "license": "Apache-2.0",
5
8
  "type": "module",
6
9
  "files": [
@@ -28,7 +31,7 @@
28
31
  },
29
32
  "dependencies": {},
30
33
  "devDependencies": {
31
- "@brainpilot/protocol": "^0.0.5",
34
+ "@brainpilot/protocol": "^0.0.6",
32
35
  "@fontsource-variable/geist": "^5.2.9",
33
36
  "@fontsource-variable/geist-mono": "^5.2.8",
34
37
  "@types/react": "^18.3.12",
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { reduceAgentsForEvent } from "../contexts/agentsReducer";
3
+ import type { AgentStatus, WebSocketEvent } from "../contracts/backend";
4
+
5
+ // #70: events arrive post-normalizeAgUiEvent (camelCase). agent_status_update's
6
+ // authoritative agent key is the top-level `name` field; status is one of
7
+ // idle|running|error|stopped (retrying normalized to running).
8
+
9
+ const ev = (name: string, status: string): WebSocketEvent =>
10
+ ({ type: "agent_status_update", name, status } as WebSocketEvent);
11
+
12
+ describe("reduceAgentsForEvent (#70)", () => {
13
+ it("appends an unknown agent with empty task", () => {
14
+ const out = reduceAgentsForEvent([], ev("librarian", "running"));
15
+ expect(out).toHaveLength(1);
16
+ expect(out[0]).toMatchObject({ name: "librarian", status: "running", task: "", alive: true });
17
+ });
18
+
19
+ it("updates a known agent's status but preserves its task", () => {
20
+ const prev: AgentStatus[] = [
21
+ { name: "librarian", status: "idle", task: "读文档", updatedAt: "t0", alive: true },
22
+ ];
23
+ const out = reduceAgentsForEvent(prev, ev("librarian", "running"));
24
+ expect(out[0]!.status).toBe("running");
25
+ expect(out[0]!.task).toBe("读文档"); // task preserved
26
+ expect(out).not.toBe(prev); // new reference on change
27
+ });
28
+
29
+ it("returns the same reference on a no-op status", () => {
30
+ const prev: AgentStatus[] = [
31
+ { name: "principal", status: "running", task: "", updatedAt: "t0", alive: true },
32
+ ];
33
+ expect(reduceAgentsForEvent(prev, ev("principal", "running"))).toBe(prev);
34
+ });
35
+
36
+ it("ignores non agent_status_update events (same reference)", () => {
37
+ const prev: AgentStatus[] = [
38
+ { name: "principal", status: "idle", task: "", updatedAt: "t0", alive: true },
39
+ ];
40
+ expect(reduceAgentsForEvent(prev, { type: "RUN_STARTED" } as WebSocketEvent)).toBe(prev);
41
+ });
42
+
43
+ it("ignores events with an empty name", () => {
44
+ const prev: AgentStatus[] = [];
45
+ expect(reduceAgentsForEvent(prev, ev("", "running"))).toBe(prev);
46
+ });
47
+
48
+ it("marks a stopped agent as not alive", () => {
49
+ const out = reduceAgentsForEvent([], ev("librarian", "stopped"));
50
+ expect(out[0]).toMatchObject({ status: "stopped", alive: false });
51
+ });
52
+
53
+ it("normalizes retrying to running for the panel", () => {
54
+ const out = reduceAgentsForEvent([], ev("principal", "retrying"));
55
+ expect(out[0]!.status).toBe("running");
56
+ });
57
+
58
+ it("merges multiple agents independently", () => {
59
+ let agents: AgentStatus[] = [];
60
+ agents = reduceAgentsForEvent(agents, ev("principal", "running"));
61
+ agents = reduceAgentsForEvent(agents, ev("librarian", "running"));
62
+ agents = reduceAgentsForEvent(agents, ev("principal", "idle"));
63
+ expect(agents).toHaveLength(2);
64
+ expect(agents.find((a) => a.name === "principal")!.status).toBe("idle");
65
+ expect(agents.find((a) => a.name === "librarian")!.status).toBe("running");
66
+ });
67
+ });
@@ -78,6 +78,64 @@ describe("api.sessions.list — unwraps { sessions } and tolerates shape", () =>
78
78
  });
79
79
  });
80
80
 
81
+ describe("api.sessions.create — unwraps the { id, session } envelope (#96)", () => {
82
+ it("reads the real title from the runtime's { id, session } envelope", async () => {
83
+ // The runtime's POST /sessions returns `{ id, session }` (server.ts), unlike
84
+ // the GET routes which return the bare session. Before the fix the whole
85
+ // envelope was handed to normalizeSession, so `raw.title` was undefined and
86
+ // the sidebar/header fell back to `Session <id8>` until a reload.
87
+ fetchMock.mockResolvedValueOnce(
88
+ makeResponse({
89
+ contentType: "application/json",
90
+ json: { id: "f8f35032", session: { id: "f8f35032", title: "请用两句话介绍 BrainPilot" } },
91
+ }),
92
+ );
93
+ const out = await api.sessions.create("请用两句话介绍 BrainPilot");
94
+ expect(out.id).toBe("f8f35032");
95
+ expect(out.title).toBe("请用两句话介绍 BrainPilot");
96
+ });
97
+
98
+ it("tolerates a bare session object (no envelope)", async () => {
99
+ fetchMock.mockResolvedValueOnce(
100
+ makeResponse({
101
+ contentType: "application/json",
102
+ json: { id: "abc", title: "bare title" },
103
+ }),
104
+ );
105
+ const out = await api.sessions.create("bare title");
106
+ expect(out.id).toBe("abc");
107
+ expect(out.title).toBe("bare title");
108
+ });
109
+ });
110
+
111
+ describe("composer-driving requests carry an abort timeout (#106)", () => {
112
+ it("create passes an AbortSignal so a hung POST can't wedge the composer", async () => {
113
+ fetchMock.mockResolvedValueOnce(
114
+ makeResponse({ contentType: "application/json", json: { id: "x", session: { id: "x", title: "t" } } }),
115
+ );
116
+ await api.sessions.create("t");
117
+ const init = fetchMock.mock.calls[0][1] as RequestInit;
118
+ expect(init.signal).toBeInstanceOf(AbortSignal);
119
+ });
120
+
121
+ it("postMessage passes an AbortSignal", async () => {
122
+ fetchMock.mockResolvedValueOnce(
123
+ makeResponse({ contentType: "application/json", json: { status: "ok" } }),
124
+ );
125
+ await api.sessions.postMessage("s1", { content: "hi", uuid: "u", timestamp: "2026-06-18T00:00:00Z" });
126
+ const init = fetchMock.mock.calls[0][1] as RequestInit;
127
+ expect(init.signal).toBeInstanceOf(AbortSignal);
128
+ });
129
+
130
+ it("propagates the timeout rejection to the caller (releases isSending upstream)", async () => {
131
+ // Simulate AbortSignal.timeout firing: fetch rejects with a TimeoutError.
132
+ fetchMock.mockRejectedValueOnce(new DOMException("timed out", "TimeoutError"));
133
+ await expect(
134
+ api.sessions.postMessage("s1", { content: "hi", uuid: "u", timestamp: "2026-06-18T00:00:00Z" }),
135
+ ).rejects.toBeInstanceOf(DOMException);
136
+ });
137
+ });
138
+
81
139
  describe("api.sessions.getEvents — tolerates SSE / non-JSON responses", () => {
82
140
  it("returns the events array for a JSON { events } body", async () => {
83
141
  fetchMock.mockResolvedValueOnce(
@@ -101,3 +159,63 @@ describe("api.sessions.getEvents — tolerates SSE / non-JSON responses", () =>
101
159
  await expect(api.sessions.getEvents("s1")).resolves.toEqual([]);
102
160
  });
103
161
  });
162
+
163
+ describe("api.sessions.getHistory — persisted events.jsonl rehydration", () => {
164
+ it("returns the envelope shape from a JSON response", async () => {
165
+ fetchMock.mockResolvedValueOnce(
166
+ makeResponse({
167
+ contentType: "application/json",
168
+ json: { events: [{ type: "TEXT_MESSAGE_CHUNK", delta: "hi" }], total: 1, truncated: false },
169
+ }),
170
+ );
171
+ const out = await api.sessions.getHistory("s1");
172
+ expect(out.events).toHaveLength(1);
173
+ expect(out.total).toBe(1);
174
+ expect(out.truncated).toBe(false);
175
+ });
176
+
177
+ it("forwards the limit query string", async () => {
178
+ fetchMock.mockResolvedValueOnce(
179
+ makeResponse({ contentType: "application/json", json: { events: [], total: 0, truncated: false } }),
180
+ );
181
+ await api.sessions.getHistory("s1", { limit: 42 });
182
+ const url = String(fetchMock.mock.calls[0]![0]);
183
+ expect(url).toContain("/sessions/s1/history?limit=42");
184
+ });
185
+
186
+ it("returns the empty envelope on a non-ok response", async () => {
187
+ fetchMock.mockResolvedValueOnce(makeResponse({ ok: false, status: 404 }));
188
+ const out = await api.sessions.getHistory("s1");
189
+ expect(out).toEqual({ events: [], total: 0, truncated: false });
190
+ });
191
+
192
+ it("returns the empty envelope when the body is null", async () => {
193
+ fetchMock.mockResolvedValueOnce(makeResponse({ contentType: "application/json", json: null }));
194
+ const out = await api.sessions.getHistory("s1");
195
+ expect(out).toEqual({ events: [], total: 0, truncated: false });
196
+ });
197
+ });
198
+
199
+ describe("api.sessions.interrupt — hits the interrupt route, not /messages (#90)", () => {
200
+ it("POSTs to /sessions/:id/interrupt and returns { interrupted }", async () => {
201
+ fetchMock.mockResolvedValueOnce(
202
+ makeResponse({ contentType: "application/json", json: { interrupted: true } }),
203
+ );
204
+ const out = await api.sessions.interrupt("s1");
205
+
206
+ const [url, init] = fetchMock.mock.calls[0]!;
207
+ expect(String(url)).toMatch(/\/sessions\/s1\/interrupt$/);
208
+ expect(String(url)).not.toMatch(/\/messages$/);
209
+ expect((init as RequestInit).method).toBe("POST");
210
+ expect(out.interrupted).toBe(true);
211
+ });
212
+
213
+ it("never routes the Stop action through the messages endpoint", async () => {
214
+ fetchMock.mockResolvedValueOnce(
215
+ makeResponse({ contentType: "application/json", json: { interrupted: true } }),
216
+ );
217
+ await api.sessions.interrupt("abc");
218
+ const url = String(fetchMock.mock.calls[0]![0]);
219
+ expect(url.endsWith("/messages")).toBe(false);
220
+ });
221
+ });
@@ -0,0 +1,49 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ getChatScroll,
4
+ setChatScroll,
5
+ resolveScrollTop,
6
+ } from "../components/chat/chatScrollMemory";
7
+
8
+ describe("chatScrollMemory (#89)", () => {
9
+ beforeEach(() => {
10
+ // Each test uses unique keys, but clear shared rows defensively.
11
+ setChatScroll("s1", { scrollTop: 0, pinned: true });
12
+ setChatScroll("s2", { scrollTop: 0, pinned: true });
13
+ });
14
+
15
+ it("stores and reads per-session state", () => {
16
+ setChatScroll("s1", { scrollTop: 120, pinned: false });
17
+ expect(getChatScroll("s1")).toEqual({ scrollTop: 120, pinned: false });
18
+ });
19
+
20
+ it("isolates sessions", () => {
21
+ setChatScroll("s1", { scrollTop: 50, pinned: false });
22
+ setChatScroll("s2", { scrollTop: 999, pinned: true });
23
+ expect(getChatScroll("s1")?.scrollTop).toBe(50);
24
+ expect(getChatScroll("s2")?.scrollTop).toBe(999);
25
+ });
26
+
27
+ it("ignores undefined keys", () => {
28
+ setChatScroll(undefined, { scrollTop: 5, pinned: false });
29
+ expect(getChatScroll(undefined)).toBeUndefined();
30
+ });
31
+
32
+ describe("resolveScrollTop", () => {
33
+ it("returns bottom when there is no memory (fresh conversation)", () => {
34
+ expect(resolveScrollTop(undefined, 4000)).toBe(4000);
35
+ });
36
+
37
+ it("returns bottom when the user was pinned (following live output)", () => {
38
+ expect(resolveScrollTop({ scrollTop: 100, pinned: true }, 4000)).toBe(4000);
39
+ });
40
+
41
+ it("restores the saved position when the user was reading history", () => {
42
+ expect(resolveScrollTop({ scrollTop: 800, pinned: false }, 4000)).toBe(800);
43
+ });
44
+
45
+ it("clamps a stale position to the current scrollHeight", () => {
46
+ expect(resolveScrollTop({ scrollTop: 9000, pinned: false }, 4000)).toBe(4000);
47
+ });
48
+ });
49
+ });
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ChatMessage } from "../contracts/backend";
3
+ import { isDemoConversational } from "../components/demo/DemoView";
4
+
5
+ function msg(overrides: Partial<ChatMessage>): ChatMessage {
6
+ return {
7
+ id: overrides.id ?? "m1",
8
+ role: overrides.role ?? "assistant",
9
+ content: overrides.content ?? "",
10
+ createdAt: "2026-06-18T00:00:00.000Z",
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ describe("isDemoConversational (#98 multi-agent transcript)", () => {
16
+ it("keeps non-empty user prompts", () => {
17
+ expect(isDemoConversational(msg({ role: "user", content: "hello" }))).toBe(true);
18
+ expect(isDemoConversational(msg({ role: "user", content: " " }))).toBe(false);
19
+ });
20
+
21
+ it("keeps the principal's substantive text replies", () => {
22
+ expect(
23
+ isDemoConversational(msg({ role: "assistant", agent: "principal", kind: "text", content: "done" })),
24
+ ).toBe(true);
25
+ });
26
+
27
+ it("keeps EXPERT agent text replies (the core #98 regression)", () => {
28
+ // librarian / engineer / experimentalist replies must survive — the live
29
+ // Chat shows them, so the demo must too.
30
+ expect(
31
+ isDemoConversational(msg({ role: "assistant", agent: "librarian", kind: "text", content: "searching…" })),
32
+ ).toBe(true);
33
+ expect(
34
+ isDemoConversational(msg({ role: "assistant", agent: "engineer", kind: undefined, content: "built it" })),
35
+ ).toBe(true);
36
+ });
37
+
38
+ it("treats a missing agent as conversational (older bundles)", () => {
39
+ expect(isDemoConversational(msg({ role: "assistant", kind: "text", content: "hi" }))).toBe(true);
40
+ });
41
+
42
+ it("keeps error bubbles with content", () => {
43
+ expect(isDemoConversational(msg({ role: "system", kind: "error", content: "librarian failed" }))).toBe(true);
44
+ expect(isDemoConversational(msg({ role: "system", kind: "error", content: "" }))).toBe(false);
45
+ });
46
+
47
+ it("keeps system_message bubbles that carry a payload", () => {
48
+ expect(
49
+ isDemoConversational(
50
+ msg({
51
+ role: "system",
52
+ kind: "system_message",
53
+ content: "",
54
+ systemMessage: { level: "error", message: "librarian error", recoverable: true },
55
+ }),
56
+ ),
57
+ ).toBe(true);
58
+ // No payload → nothing to render.
59
+ expect(isDemoConversational(msg({ role: "system", kind: "system_message", content: "" }))).toBe(false);
60
+ });
61
+
62
+ it("drops reasoning, tool calls/results, hooks and interactive cards", () => {
63
+ expect(isDemoConversational(msg({ kind: "thinking", content: "let me think" }))).toBe(false);
64
+ expect(isDemoConversational(msg({ kind: "tool", content: "Tool: read" }))).toBe(false);
65
+ expect(isDemoConversational(msg({ role: "system", kind: "hook", content: "reset" }))).toBe(false);
66
+ expect(isDemoConversational(msg({ kind: "ask_user", content: "pick one" }))).toBe(false);
67
+ expect(isDemoConversational(msg({ kind: "auto_retry", content: "retrying" }))).toBe(false);
68
+ });
69
+
70
+ it("drops empty text placeholders", () => {
71
+ expect(isDemoConversational(msg({ role: "assistant", kind: "text", content: " " }))).toBe(false);
72
+ });
73
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldResetDemo } from "../components/demo/demoReset";
3
+
4
+ describe("shouldResetDemo (#111 sidebar re-click returns to landing)", () => {
5
+ it("does not reset on the initial mount (same value)", () => {
6
+ // prev === next on first render — packing/importing a bundle must survive.
7
+ expect(shouldResetDemo(0, 0)).toBe(false);
8
+ expect(shouldResetDemo(3, 3)).toBe(false);
9
+ });
10
+
11
+ it("resets when the signal advances (sidebar clicked again)", () => {
12
+ expect(shouldResetDemo(0, 1)).toBe(true);
13
+ expect(shouldResetDemo(1, 2)).toBe(true);
14
+ });
15
+
16
+ it("treats an undefined signal (standalone mount) as no reset", () => {
17
+ // DemoView mounted without the prop: prev and next are both undefined.
18
+ expect(shouldResetDemo(undefined, undefined)).toBe(false);
19
+ });
20
+
21
+ it("resets when transitioning from undefined to a number", () => {
22
+ expect(shouldResetDemo(undefined, 1)).toBe(true);
23
+ });
24
+ });
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { runningToastLabel } from "../contexts/runningToast";
3
+
4
+ describe("runningToastLabel (#76)", () => {
5
+ it("names a single working agent", () => {
6
+ expect(runningToastLabel(["librarian"])).toEqual({
7
+ key: "chat.agentWorking",
8
+ vars: { name: "librarian" },
9
+ });
10
+ });
11
+
12
+ it("joins multiple working agents", () => {
13
+ expect(runningToastLabel(["principal", "librarian"])).toEqual({
14
+ key: "chat.agentsWorking",
15
+ vars: { names: "principal、librarian" },
16
+ });
17
+ });
18
+
19
+ it("uses a custom separator when provided", () => {
20
+ expect(runningToastLabel(["a", "b"], ", ")).toEqual({
21
+ key: "chat.agentsWorking",
22
+ vars: { names: "a, b" },
23
+ });
24
+ });
25
+
26
+ it("falls back to the generic label when no named agent is running", () => {
27
+ expect(runningToastLabel([])).toEqual({ key: "chat.agentThinking" });
28
+ });
29
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * normalizeSessionState parses the optional `tokenUsage` carried on
3
+ * session_state frames into the camelCase SessionTokenUsage shape, and tolerates
4
+ * its absence (older runtime / pre-first-turn).
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import { normalizeSessionState } from "../contracts/backend";
8
+
9
+ describe("normalizeSessionState tokenUsage", () => {
10
+ it("parses total + per-agent breakdown", () => {
11
+ const snap = normalizeSessionState({
12
+ run_state: { active: false, run_id: null },
13
+ agents: [],
14
+ last_activity_ts: "2026-06-20T00:00:00.000Z",
15
+ token_usage: {
16
+ total: { input: 30, output: 12, cache_read: 4, cache_write: 1, total: 47 },
17
+ by_agent: {
18
+ principal: { input: 20, output: 8, cache_read: 4, cache_write: 1, total: 33 },
19
+ librarian: { input: 10, output: 4, cache_read: 0, cache_write: 0, total: 14 },
20
+ },
21
+ },
22
+ });
23
+ expect(snap.tokenUsage).toBeDefined();
24
+ expect(snap.tokenUsage!.total.total).toBe(47);
25
+ expect(snap.tokenUsage!.total.cacheRead).toBe(4);
26
+ expect(snap.tokenUsage!.byAgent.principal.input).toBe(20);
27
+ expect(snap.tokenUsage!.byAgent.librarian.total).toBe(14);
28
+ });
29
+
30
+ it("omits tokenUsage when absent and coerces missing numbers to 0", () => {
31
+ const snap = normalizeSessionState({
32
+ run_state: { active: true, run_id: "run_1" },
33
+ agents: [],
34
+ last_activity_ts: "2026-06-20T00:00:00.000Z",
35
+ });
36
+ expect(snap.tokenUsage).toBeUndefined();
37
+
38
+ const partial = normalizeSessionState({
39
+ run_state: { active: true, run_id: "run_1" },
40
+ agents: [],
41
+ last_activity_ts: "2026-06-20T00:00:00.000Z",
42
+ token_usage: { total: { input: 5 }, by_agent: {} },
43
+ });
44
+ expect(partial.tokenUsage!.total.input).toBe(5);
45
+ expect(partial.tokenUsage!.total.output).toBe(0);
46
+ expect(partial.tokenUsage!.total.total).toBe(0);
47
+ });
48
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatToolName, formatPayload } from "../utils/toolDisplay";
3
+
4
+ describe("formatToolName (#84)", () => {
5
+ it("maps mcp__server__tool to 'server · tool'", () => {
6
+ expect(formatToolName("mcp__bp_skills__skills_tool")).toBe("bp_skills · skills_tool");
7
+ });
8
+
9
+ it("keeps server/tool segments that contain underscores", () => {
10
+ expect(formatToolName("mcp__my_server__do_a_thing")).toBe("my_server · do_a_thing");
11
+ });
12
+
13
+ it("returns non-MCP names unchanged", () => {
14
+ expect(formatToolName("read")).toBe("read");
15
+ expect(formatToolName("send_message")).toBe("send_message");
16
+ });
17
+
18
+ it("falls back gracefully for missing/empty names", () => {
19
+ expect(formatToolName(undefined)).toBe("tool");
20
+ expect(formatToolName(null)).toBe("tool");
21
+ expect(formatToolName("")).toBe("tool");
22
+ });
23
+
24
+ it("does not crash on a malformed mcp__ prefix with no tool segment", () => {
25
+ // No second `__` — show the remainder rather than the raw identifier.
26
+ expect(formatToolName("mcp__justserver")).toBe("justserver");
27
+ });
28
+ });
29
+
30
+ describe("formatPayload (#84)", () => {
31
+ it("parses a JSON string so it is not double-escaped", () => {
32
+ const raw = JSON.stringify({ path: "a/b.txt", count: 2 });
33
+ const out = formatPayload(raw);
34
+ expect(out).toBe('{\n "path": "a/b.txt",\n "count": 2\n}');
35
+ expect(out).not.toContain('\\"');
36
+ });
37
+
38
+ it("pretty-prints a plain object", () => {
39
+ expect(formatPayload({ a: 1 })).toBe('{\n "a": 1\n}');
40
+ });
41
+
42
+ it("returns non-JSON strings verbatim", () => {
43
+ expect(formatPayload("just some text")).toBe("just some text");
44
+ });
45
+
46
+ it("returns a partial/invalid JSON string verbatim", () => {
47
+ expect(formatPayload('{"path": "a/b')).toBe('{"path": "a/b');
48
+ });
49
+
50
+ it("returns empty string for null/undefined/blank", () => {
51
+ expect(formatPayload(undefined)).toBe("");
52
+ expect(formatPayload(null)).toBe("");
53
+ expect(formatPayload(" ")).toBe("");
54
+ });
55
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { reduceTraceForEvent } from "../contexts/traceReducer";
3
+ import type { TraceGraph, WebSocketEvent } from "../contracts/backend";
4
+
5
+ // #79: trace nodes arrive live as CUSTOM { name:"trace_node", value:{ op, node } }.
6
+
7
+ const node = (id: string, extra: Record<string, unknown> = {}) => ({
8
+ id,
9
+ title: `node ${id}`,
10
+ type: "task",
11
+ status: "completed",
12
+ parents: [],
13
+ parentIds: [],
14
+ childIds: [],
15
+ artifacts: [],
16
+ toolCalls: [],
17
+ ...extra,
18
+ });
19
+
20
+ const traceEv = (op: string, n: Record<string, unknown>): WebSocketEvent =>
21
+ ({ type: "CUSTOM", name: "trace_node", value: { op, node: n } } as unknown as WebSocketEvent);
22
+
23
+ describe("reduceTraceForEvent (#79)", () => {
24
+ it("seeds a graph from null on the first node", () => {
25
+ const out = reduceTraceForEvent(null, traceEv("created", node("a")), "s");
26
+ expect(out).not.toBeNull();
27
+ expect(out!.nodes.map((n) => n.id)).toEqual(["a"]);
28
+ expect(out!.meta.sessionId).toBe("s");
29
+ });
30
+
31
+ it("appends a new node id", () => {
32
+ const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a")] };
33
+ const out = reduceTraceForEvent(start, traceEv("created", node("b")), "s");
34
+ expect(out!.nodes.map((n) => n.id)).toEqual(["a", "b"]);
35
+ });
36
+
37
+ it("replaces an existing node in place on update", () => {
38
+ const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a", { status: "running" })] };
39
+ const out = reduceTraceForEvent(start, traceEv("updated", node("a", { status: "completed" })), "s");
40
+ expect(out!.nodes).toHaveLength(1);
41
+ expect(out!.nodes[0]!.status).toBe("completed");
42
+ });
43
+
44
+ it("recomputes childIds from parent links", () => {
45
+ const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a")] };
46
+ const child = node("b", { parents: [{ id: "a", relation: "follows" }], parentIds: ["a"] });
47
+ const out = reduceTraceForEvent(start, traceEv("created", child), "s");
48
+ const parent = out!.nodes.find((n) => n.id === "a")!;
49
+ expect(parent.childIds).toEqual(["b"]);
50
+ });
51
+
52
+ it("ignores non trace_node events (same reference)", () => {
53
+ const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a")] };
54
+ expect(reduceTraceForEvent(start, { type: "RUN_STARTED" } as WebSocketEvent, "s")).toBe(start);
55
+ });
56
+
57
+ it("ignores a payload with no node id (same reference)", () => {
58
+ const start: TraceGraph = { meta: { sessionId: "s" }, nodes: [node("a")] };
59
+ const bad = { type: "CUSTOM", name: "trace_node", value: { op: "created", node: {} } } as unknown as WebSocketEvent;
60
+ expect(reduceTraceForEvent(start, bad, "s")).toBe(start);
61
+ });
62
+ });