@brainpilot/web 0.0.5 → 0.0.7

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 (58) hide show
  1. package/dist/assets/index-DWOsU22G.css +1 -0
  2. package/dist/assets/index-j3rGyO6m.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +6 -3
  5. package/src/__tests__/agentsReducer.test.ts +67 -0
  6. package/src/__tests__/api.test.ts +118 -0
  7. package/src/__tests__/chatScrollBehavior.test.ts +48 -0
  8. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  9. package/src/__tests__/demoConversation.test.ts +96 -0
  10. package/src/__tests__/demoReset.test.ts +24 -0
  11. package/src/__tests__/internalToolStrip.test.ts +108 -0
  12. package/src/__tests__/runningToast.test.ts +29 -0
  13. package/src/__tests__/tokenUsage.test.ts +48 -0
  14. package/src/__tests__/toolDisplay.test.ts +55 -0
  15. package/src/__tests__/traceReducer.test.ts +62 -0
  16. package/src/components/chat/MessageStream.tsx +104 -56
  17. package/src/components/chat/PromptComposer.tsx +120 -29
  18. package/src/components/chat/chatScrollMemory.ts +49 -0
  19. package/src/components/demo/DemoView.tsx +98 -29
  20. package/src/components/demo/TraceNodeModal.tsx +6 -2
  21. package/src/components/demo/demoBundle.ts +7 -2
  22. package/src/components/demo/demoReset.ts +16 -0
  23. package/src/components/session/AgentNetwork.tsx +68 -75
  24. package/src/components/session/AgentTraceViews.tsx +35 -70
  25. package/src/components/session/AnalyticsTab.tsx +58 -224
  26. package/src/components/session/TraceGraphView.tsx +36 -30
  27. package/src/components/session/TraceNodeDetail.tsx +61 -24
  28. package/src/components/session/agentNetworkShared.ts +10 -0
  29. package/src/components/session/traceLayout.ts +32 -0
  30. package/src/components/settings/SettingsDialog.tsx +19 -1
  31. package/src/components/shell/DesktopShell.tsx +72 -17
  32. package/src/components/sidebar/SessionList.tsx +127 -0
  33. package/src/components/sidebar/Sidebar.tsx +94 -98
  34. package/src/contexts/SSEContext.tsx +90 -1
  35. package/src/contexts/SessionContext.tsx +397 -43
  36. package/src/contexts/agentsReducer.ts +49 -0
  37. package/src/contexts/messageGroups.ts +56 -0
  38. package/src/contexts/messageReducer.ts +4 -0
  39. package/src/contexts/runningToast.ts +33 -0
  40. package/src/contexts/traceReducer.ts +62 -0
  41. package/src/contexts/turnTimer.test.ts +97 -0
  42. package/src/contexts/turnTimer.ts +108 -0
  43. package/src/contexts/useTurnTimer.ts +104 -0
  44. package/src/contracts/backend.ts +53 -2
  45. package/src/i18n/messages/analytics.ts +16 -6
  46. package/src/i18n/messages/chat.ts +26 -4
  47. package/src/i18n/messages/contexts.ts +2 -0
  48. package/src/i18n/messages/network.ts +13 -9
  49. package/src/i18n/messages/profile.ts +4 -0
  50. package/src/i18n/messages/settings.ts +4 -0
  51. package/src/i18n/messages/shell.ts +2 -0
  52. package/src/i18n/messages/trace.ts +69 -17
  53. package/src/mocks/backend.ts +7 -0
  54. package/src/styles/global.css +289 -70
  55. package/src/utils/api.ts +105 -8
  56. package/src/utils/toolDisplay.ts +74 -0
  57. package/dist/assets/index-C-8G4D4j.js +0 -448
  58. 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-j3rGyO6m.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DWOsU22G.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@brainpilot/web",
3
- "version": "0.0.5",
4
- "license": "Apache-2.0",
3
+ "version": "0.0.7",
4
+ "engines": {
5
+ "node": ">=22"
6
+ },
7
+ "license": "AGPL-3.0-only",
5
8
  "type": "module",
6
9
  "files": [
7
10
  "dist",
@@ -28,7 +31,7 @@
28
31
  },
29
32
  "dependencies": {},
30
33
  "devDependencies": {
31
- "@brainpilot/protocol": "^0.0.5",
34
+ "@brainpilot/protocol": "^0.0.7",
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,48 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // #133 — chat restore must land at the bottom instantly when the user was
6
+ // pinned, with NO visible top-to-bottom smooth-scroll replay.
7
+ //
8
+ // MessageStream restores scroll position imperatively (`scrollTop = …`) on
9
+ // tab-switch remount and on pinned-bottom live append. A global
10
+ // `scroll-behavior: smooth` on the scroll container turns those instant jumps
11
+ // into an animation through the history — exactly the jumpiness this guards.
12
+ //
13
+ // The repo has no jsdom/happy-dom (see vitest.config.ts), so we can't drive a
14
+ // real scroll. Instead we assert the *intent* at its two sources of truth:
15
+ // 1. the container CSS does not opt the stack into smooth scrolling, and
16
+ // 2. the component pins `scroll-behavior: auto` locally before each
17
+ // imperative scroll write (belt-and-suspenders against an inherited rule).
18
+ const cssPath = fileURLToPath(new URL("../styles/global.css", import.meta.url));
19
+ const streamPath = fileURLToPath(
20
+ new URL("../components/chat/MessageStream.tsx", import.meta.url),
21
+ );
22
+ const css = readFileSync(cssPath, "utf8");
23
+ const stream = readFileSync(streamPath, "utf8");
24
+
25
+ /** Extract the body of a top-level `.selector { … }` rule from a stylesheet. */
26
+ function ruleBody(source: string, selector: string): string {
27
+ const start = source.indexOf(`${selector} {`);
28
+ if (start < 0) throw new Error(`rule not found: ${selector}`);
29
+ const open = source.indexOf("{", start);
30
+ const close = source.indexOf("}", open);
31
+ return source.slice(open + 1, close);
32
+ }
33
+
34
+ describe("#133 chat scroll restore is instant, not smooth", () => {
35
+ it(".message-stack does not declare smooth scrolling", () => {
36
+ // Strip CSS comments first — the rule deliberately documents WHY smooth is
37
+ // absent, and that prose mentions the property name.
38
+ const body = ruleBody(css, ".message-stack").replace(/\/\*[\s\S]*?\*\//g, "");
39
+ expect(body).not.toMatch(/scroll-behavior\s*:\s*smooth/);
40
+ });
41
+
42
+ it("MessageStream forces scroll-behavior auto before imperative restore", () => {
43
+ // The mount-restore effect and the pinned-bottom append effect both set
44
+ // scrollTop; each must pin auto first so neither animates.
45
+ const autoWrites = stream.match(/scrollBehavior\s*=\s*["']auto["']/g) ?? [];
46
+ expect(autoWrites.length).toBeGreaterThanOrEqual(2);
47
+ });
48
+ });
@@ -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,96 @@
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 auto_retry 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: "auto_retry", content: "retrying" }))).toBe(false);
67
+ });
68
+
69
+ it("keeps an answered ask_user card but drops an unanswered prompt (#132)", () => {
70
+ // Answered: question + user answer are a user-facing decision point, kept as
71
+ // a read-only Q&A step in the replay.
72
+ expect(
73
+ isDemoConversational(
74
+ msg({
75
+ kind: "ask_user",
76
+ content: "pick one",
77
+ askUser: { requestId: "req_1", agent: "principal", question: "pick one", answer: "A" },
78
+ }),
79
+ ),
80
+ ).toBe(true);
81
+ // Unanswered prompt has no meaning in a read-only replay.
82
+ expect(
83
+ isDemoConversational(
84
+ msg({
85
+ kind: "ask_user",
86
+ content: "pick one",
87
+ askUser: { requestId: "req_1", agent: "principal", question: "pick one" },
88
+ }),
89
+ ),
90
+ ).toBe(false);
91
+ });
92
+
93
+ it("drops empty text placeholders", () => {
94
+ expect(isDemoConversational(msg({ role: "assistant", kind: "text", content: " " }))).toBe(false);
95
+ });
96
+ });
@@ -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,108 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ buildRenderItems,
4
+ isInternalToolName,
5
+ stripInternalToolMessages,
6
+ } from "../contexts/messageGroups";
7
+ import type { ChatMessage } from "../contracts/backend";
8
+
9
+ // #134 — record_trace (and the trace-agent graph tools) are internal plumbing.
10
+ // Their tool call AND result must be hidden from the chat stream, while the
11
+ // model still receives them. These cover the pure presentation filter.
12
+
13
+ function toolCall(over: Partial<ChatMessage> = {}): ChatMessage {
14
+ return {
15
+ id: over.id ?? "call-1",
16
+ role: "assistant",
17
+ content: "Tool: record_trace",
18
+ createdAt: "2026-06-21T00:00:00.000Z",
19
+ agent: "principal",
20
+ kind: "tool",
21
+ toolName: "record_trace",
22
+ ...over,
23
+ };
24
+ }
25
+
26
+ function toolResult(over: Partial<ChatMessage> = {}): ChatMessage {
27
+ return {
28
+ id: over.id ?? "res-1",
29
+ role: "assistant",
30
+ content: "Tool result",
31
+ createdAt: "2026-06-21T00:00:00.000Z",
32
+ agent: "principal",
33
+ kind: "tool",
34
+ toolResult: "trace event dispatched",
35
+ toolCallId: "call-1",
36
+ ...over,
37
+ };
38
+ }
39
+
40
+ function assistantText(content: string): ChatMessage {
41
+ return {
42
+ id: `t-${content}`,
43
+ role: "assistant",
44
+ content,
45
+ createdAt: "2026-06-21T00:00:00.000Z",
46
+ agent: "principal",
47
+ kind: "text",
48
+ };
49
+ }
50
+
51
+ describe("isInternalToolName", () => {
52
+ it("matches bare internal tool names", () => {
53
+ expect(isInternalToolName("record_trace")).toBe(true);
54
+ expect(isInternalToolName("create_trace_node")).toBe(true);
55
+ expect(isInternalToolName("get_trace_graph")).toBe(true);
56
+ });
57
+
58
+ it("matches mcp-namespaced internal tool names", () => {
59
+ expect(isInternalToolName("mcp__brainpilot__record_trace")).toBe(true);
60
+ });
61
+
62
+ it("does not match user-facing tools", () => {
63
+ expect(isInternalToolName("send_message")).toBe(false);
64
+ expect(isInternalToolName("skill_search")).toBe(false);
65
+ expect(isInternalToolName(undefined)).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe("stripInternalToolMessages (#134)", () => {
70
+ it("drops the record_trace call and its linked result", () => {
71
+ const out = stripInternalToolMessages([
72
+ assistantText("before"),
73
+ toolCall(),
74
+ toolResult(),
75
+ assistantText("after"),
76
+ ]);
77
+ expect(out.map((m) => m.id)).toEqual(["t-before", "t-after"]);
78
+ });
79
+
80
+ it("keeps user-facing tool calls and results", () => {
81
+ const sendCall = toolCall({ id: "s1", toolName: "send_message", content: "Tool: send_message" });
82
+ const sendResult = toolResult({ id: "s2", toolCallId: "s1", toolResult: "ok" });
83
+ const out = stripInternalToolMessages([sendCall, sendResult]);
84
+ expect(out.map((m) => m.id)).toEqual(["s1", "s2"]);
85
+ });
86
+
87
+ it("returns the same reference when there is nothing internal", () => {
88
+ const msgs = [assistantText("a"), assistantText("b")];
89
+ expect(stripInternalToolMessages(msgs)).toBe(msgs);
90
+ });
91
+
92
+ it("buildRenderItems hides an isolated internal tool block entirely", () => {
93
+ // A lone record_trace call+result would otherwise fold into one activity
94
+ // block; after stripping there is nothing to render.
95
+ const items = buildRenderItems([toolCall(), toolResult()]);
96
+ expect(items).toEqual([]);
97
+ });
98
+
99
+ it("buildRenderItems keeps surrounding conversation intact", () => {
100
+ const items = buildRenderItems([
101
+ assistantText("question?"),
102
+ toolCall(),
103
+ toolResult(),
104
+ ]);
105
+ expect(items).toHaveLength(1);
106
+ expect(items[0]).toMatchObject({ type: "single" });
107
+ });
108
+ });
@@ -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
+ });