@brainpilot/web 0.0.4 → 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 (114) 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/index.html +13 -0
  5. package/package.json +12 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/agentsReducer.test.ts +67 -0
  8. package/src/__tests__/api.test.ts +221 -0
  9. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  10. package/src/__tests__/demoConversation.test.ts +73 -0
  11. package/src/__tests__/demoReset.test.ts +24 -0
  12. package/src/__tests__/messageGroups.test.ts +80 -0
  13. package/src/__tests__/newUiComponents.test.tsx +101 -0
  14. package/src/__tests__/newUiEvents.test.ts +236 -0
  15. package/src/__tests__/runningToast.test.ts +29 -0
  16. package/src/__tests__/tokenUsage.test.ts +48 -0
  17. package/src/__tests__/toolDisplay.test.ts +55 -0
  18. package/src/__tests__/traceReducer.test.ts +62 -0
  19. package/src/components/chat/AskUserCard.tsx +123 -0
  20. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  21. package/src/components/chat/ComposerInput.tsx +73 -0
  22. package/src/components/chat/ComposerSendButton.tsx +26 -0
  23. package/src/components/chat/MarkdownMessage.tsx +24 -0
  24. package/src/components/chat/MessageStream.tsx +505 -0
  25. package/src/components/chat/PromptComposer.tsx +489 -0
  26. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  27. package/src/components/chat/chatScrollMemory.ts +49 -0
  28. package/src/components/demo/DemoFileTree.tsx +146 -0
  29. package/src/components/demo/DemoView.tsx +730 -0
  30. package/src/components/demo/TraceNodeModal.tsx +80 -0
  31. package/src/components/demo/demoBundle.ts +223 -0
  32. package/src/components/demo/demoCache.ts +42 -0
  33. package/src/components/demo/demoReset.ts +16 -0
  34. package/src/components/files/FilePreviewView.tsx +153 -0
  35. package/src/components/files/FileSidebar.tsx +664 -0
  36. package/src/components/files/filePreview.ts +113 -0
  37. package/src/components/primitives/CustomSelect.tsx +200 -0
  38. package/src/components/primitives/IconButton.tsx +27 -0
  39. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  40. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  41. package/src/components/quota/QuotaFileManager.tsx +197 -0
  42. package/src/components/search/SearchDialog.tsx +101 -0
  43. package/src/components/session/AgentNetwork.tsx +1233 -0
  44. package/src/components/session/AgentTraceViews.tsx +346 -0
  45. package/src/components/session/AnalyticsTab.tsx +220 -0
  46. package/src/components/session/GlobalOverview.tsx +108 -0
  47. package/src/components/session/NodeTooltip.tsx +127 -0
  48. package/src/components/session/TimelineTab.tsx +320 -0
  49. package/src/components/session/TraceGraphView.tsx +307 -0
  50. package/src/components/session/TraceNodeDetail.tsx +179 -0
  51. package/src/components/session/agentAnalytics.ts +397 -0
  52. package/src/components/session/agentNetworkShared.ts +339 -0
  53. package/src/components/session/traceLayout.ts +182 -0
  54. package/src/components/settings/SettingsDialog.tsx +737 -0
  55. package/src/components/shell/DesktopShell.tsx +261 -0
  56. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  57. package/src/components/shell/SandboxStatus.tsx +287 -0
  58. package/src/components/shell/TerminalDrawer.tsx +387 -0
  59. package/src/components/sidebar/Sidebar.tsx +191 -0
  60. package/src/config.ts +10 -0
  61. package/src/contexts/AppProviders.tsx +20 -0
  62. package/src/contexts/AuthContext.tsx +61 -0
  63. package/src/contexts/PreferencesContext.tsx +125 -0
  64. package/src/contexts/SSEContext.tsx +264 -0
  65. package/src/contexts/SandboxContext.tsx +310 -0
  66. package/src/contexts/SessionContext.tsx +919 -0
  67. package/src/contexts/agentsReducer.ts +49 -0
  68. package/src/contexts/draftStore.ts +103 -0
  69. package/src/contexts/messageFilters.ts +29 -0
  70. package/src/contexts/messageGroups.ts +77 -0
  71. package/src/contexts/messageReducer.ts +401 -0
  72. package/src/contexts/newUiEvents.ts +190 -0
  73. package/src/contexts/runningToast.ts +33 -0
  74. package/src/contexts/traceReducer.ts +62 -0
  75. package/src/contexts/turnTimer.test.ts +97 -0
  76. package/src/contexts/turnTimer.ts +108 -0
  77. package/src/contexts/useTurnTimer.ts +104 -0
  78. package/src/contracts/backend.ts +897 -0
  79. package/src/contracts/demoBundle.ts +83 -0
  80. package/src/i18n/messages/analytics.ts +106 -0
  81. package/src/i18n/messages/chat.ts +130 -0
  82. package/src/i18n/messages/contexts.ts +42 -0
  83. package/src/i18n/messages/demo.ts +80 -0
  84. package/src/i18n/messages/files.ts +82 -0
  85. package/src/i18n/messages/network.ts +190 -0
  86. package/src/i18n/messages/profile.ts +44 -0
  87. package/src/i18n/messages/quota.ts +36 -0
  88. package/src/i18n/messages/sandbox.ts +116 -0
  89. package/src/i18n/messages/search.ts +16 -0
  90. package/src/i18n/messages/settings.ts +188 -0
  91. package/src/i18n/messages/shell.ts +38 -0
  92. package/src/i18n/messages/sidebar.ts +52 -0
  93. package/src/i18n/messages/terminal.ts +22 -0
  94. package/src/i18n/messages/trace.ts +136 -0
  95. package/src/i18n/messages.ts +32 -0
  96. package/src/i18n/translate.ts +46 -0
  97. package/src/i18n/types.ts +15 -0
  98. package/src/i18n/useT.ts +15 -0
  99. package/src/main.tsx +13 -0
  100. package/src/mocks/backend.ts +729 -0
  101. package/src/styles/global.css +7578 -0
  102. package/src/styles/tokens.css +161 -0
  103. package/src/utils/api.ts +724 -0
  104. package/src/utils/download.ts +18 -0
  105. package/src/utils/format.ts +7 -0
  106. package/src/utils/toolDisplay.ts +74 -0
  107. package/src/utils/zip.ts +119 -0
  108. package/src/vite-env.d.ts +1 -0
  109. package/tsconfig.app.json +22 -0
  110. package/tsconfig.json +7 -0
  111. package/tsconfig.node.json +13 -0
  112. package/vite.config.ts +13 -0
  113. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  114. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+
3
+ // No jsdom/@testing-library in the monorepo (deps not installable here), so we
4
+ // render to static markup with react-dom/server and assert on the output. This
5
+ // covers the level→class mapping for the bubble and the structural shape of the
6
+ // ask_user card and auto-retry indicator. Interaction (submit/cancel) is covered
7
+ // by the pure helpers in newUiEvents.test.ts (resolveAskUserSubmission etc.),
8
+ // which are the exact functions these components call.
9
+ vi.mock("../i18n/useT", () => ({
10
+ useT: () => (k: string, v?: Record<string, unknown>) => (v ? `${k}:${JSON.stringify(v)}` : k),
11
+ }));
12
+
13
+ import { renderToStaticMarkup } from "react-dom/server";
14
+ import { SystemMessageBubble } from "../components/chat/SystemMessageBubble";
15
+ import { AskUserCard } from "../components/chat/AskUserCard";
16
+ import { AutoRetryIndicator } from "../components/chat/AutoRetryIndicator";
17
+
18
+ describe("SystemMessageBubble — 4 levels", () => {
19
+ for (const level of ["info", "warning", "error", "fatal"] as const) {
20
+ it(`renders the ${level} bubble`, () => {
21
+ const html = renderToStaticMarkup(
22
+ <SystemMessageBubble
23
+ view={{ level, message: `${level} text`, recoverable: level !== "fatal" }}
24
+ />,
25
+ );
26
+ expect(html).toContain(`system-message--${level}`);
27
+ expect(html).toContain(`${level} text`);
28
+ expect(html).toContain(`data-level="${level}"`);
29
+ });
30
+ }
31
+
32
+ it("fatal gets the emphasis modifier + alert role", () => {
33
+ const html = renderToStaticMarkup(
34
+ <SystemMessageBubble view={{ level: "fatal", message: "boom", recoverable: false }} />,
35
+ );
36
+ expect(html).toContain("system-message--emphasis");
37
+ expect(html).toContain('role="alert"');
38
+ });
39
+
40
+ it("renders an expandable details block when details present", () => {
41
+ const html = renderToStaticMarkup(
42
+ <SystemMessageBubble
43
+ view={{ level: "error", message: "oops", details: "stack-trace-here", recoverable: true }}
44
+ />,
45
+ );
46
+ expect(html).toContain("<details");
47
+ expect(html).toContain("stack-trace-here");
48
+ });
49
+ });
50
+
51
+ describe("AskUserCard — structure", () => {
52
+ it("renders option buttons + free-text input when open", () => {
53
+ const html = renderToStaticMarkup(
54
+ <AskUserCard
55
+ view={{ requestId: "r1", agent: "principal", question: "Pick", options: ["A", "B"], allowFreeText: true }}
56
+ onSubmit={() => {}}
57
+ />,
58
+ );
59
+ expect(html).toContain("ask-user");
60
+ expect(html).toContain('data-request-id="r1"');
61
+ expect(html).toContain(">A<");
62
+ expect(html).toContain(">B<");
63
+ expect(html).toContain("ask-user__input");
64
+ });
65
+
66
+ it("renders the answered state once resolved", () => {
67
+ const html = renderToStaticMarkup(
68
+ <AskUserCard
69
+ view={{ requestId: "r1", agent: "principal", question: "Pick", answer: "A" }}
70
+ onSubmit={() => {}}
71
+ />,
72
+ );
73
+ expect(html).toContain("ask-user--answered");
74
+ expect(html).not.toContain("ask-user__option");
75
+ });
76
+ });
77
+
78
+ describe("AutoRetryIndicator — structure", () => {
79
+ it("renders countdown + cancel button while active", () => {
80
+ const html = renderToStaticMarkup(
81
+ <AutoRetryIndicator
82
+ view={{ attempt: 2, maxAttempts: 5, delayMs: 3000 }}
83
+ onCancel={() => {}}
84
+ />,
85
+ );
86
+ expect(html).toContain("auto-retry");
87
+ expect(html).toContain("auto-retry__cancel");
88
+ expect(html).toContain("chat.retry.attempt");
89
+ });
90
+
91
+ it("hides the cancel button once cancelled", () => {
92
+ const html = renderToStaticMarkup(
93
+ <AutoRetryIndicator
94
+ view={{ attempt: 2, maxAttempts: 5, delayMs: 3000, cancelled: true }}
95
+ onCancel={() => {}}
96
+ />,
97
+ );
98
+ expect(html).toContain("auto-retry--cancelled");
99
+ expect(html).not.toContain("auto-retry__cancel");
100
+ });
101
+ });
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ toSystemMessageView,
4
+ toAskUserView,
5
+ toAutoRetryView,
6
+ isAutoRetryStatus,
7
+ resolveAskUserSubmission,
8
+ isAskUserOpen,
9
+ autoRetryCountdownSeconds,
10
+ } from "../contexts/newUiEvents";
11
+ import { reduceMessagesForEvent } from "../contexts/messageReducer";
12
+ import type { ChatMessage, WebSocketEvent } from "../contracts/backend";
13
+
14
+ // Events arrive post-normalizeAgUiEvent (camelCase). We exercise the mapping +
15
+ // reducer with that shape, plus tolerate the raw snake_case fallback.
16
+
17
+ describe("system_message mapping", () => {
18
+ it("maps each level and defaults recoverable", () => {
19
+ for (const level of ["info", "warning", "error"] as const) {
20
+ const v = toSystemMessageView({ type: "system_message", level, message: "m" } as WebSocketEvent);
21
+ expect(v.level).toBe(level);
22
+ expect(v.recoverable).toBe(true); // non-fatal defaults recoverable
23
+ }
24
+ const fatal = toSystemMessageView({ type: "system_message", level: "fatal", message: "boom" } as WebSocketEvent);
25
+ expect(fatal.level).toBe("fatal");
26
+ expect(fatal.recoverable).toBe(false); // fatal defaults non-recoverable
27
+ });
28
+
29
+ it("honors explicit recoverable + details + agent", () => {
30
+ const v = toSystemMessageView({
31
+ type: "system_message",
32
+ level: "fatal",
33
+ message: "m",
34
+ details: "stacktrace",
35
+ agent: "librarian",
36
+ recoverable: true,
37
+ } as WebSocketEvent);
38
+ expect(v.recoverable).toBe(true);
39
+ expect(v.details).toBe("stacktrace");
40
+ expect(v.agent).toBe("librarian");
41
+ });
42
+
43
+ it("falls back to info for unknown level", () => {
44
+ const v = toSystemMessageView({ type: "system_message", level: "bogus", message: "m" } as WebSocketEvent);
45
+ expect(v.level).toBe("info");
46
+ });
47
+
48
+ it("reduces into a system_message ChatMessage", () => {
49
+ const out = reduceMessagesForEvent([], {
50
+ type: "system_message",
51
+ level: "warning",
52
+ message: "watch out",
53
+ } as WebSocketEvent);
54
+ expect(out).toHaveLength(1);
55
+ expect(out[0].kind).toBe("system_message");
56
+ expect(out[0].systemMessage?.level).toBe("warning");
57
+ expect(out[0].content).toBe("watch out");
58
+ });
59
+ });
60
+
61
+ describe("ask_user mapping + submit + reducer round-trip", () => {
62
+ const event = {
63
+ type: "user_input_request",
64
+ requestId: "req-42",
65
+ agent: "principal",
66
+ question: "Pick one",
67
+ options: ["A", "B"],
68
+ allowFreeText: true,
69
+ timeoutSec: 30,
70
+ } as WebSocketEvent;
71
+
72
+ it("maps camelCase wire fields", () => {
73
+ const v = toAskUserView(event);
74
+ expect(v.requestId).toBe("req-42");
75
+ expect(v.options).toEqual(["A", "B"]);
76
+ expect(v.allowFreeText).toBe(true);
77
+ expect(v.timeoutSec).toBe(30);
78
+ });
79
+
80
+ it("tolerates snake_case fallback", () => {
81
+ const v = toAskUserView({
82
+ type: "user_input_request",
83
+ request_id: "rid",
84
+ agent: "a",
85
+ question: "q",
86
+ allow_free_text: true,
87
+ timeout_sec: 10,
88
+ } as unknown as WebSocketEvent);
89
+ expect(v.requestId).toBe("rid");
90
+ expect(v.allowFreeText).toBe(true);
91
+ expect(v.timeoutSec).toBe(10);
92
+ });
93
+
94
+ it("reduces into an ask_user card keyed by requestId, dedupes re-emit", () => {
95
+ let msgs = reduceMessagesForEvent([], event);
96
+ expect(msgs).toHaveLength(1);
97
+ expect(msgs[0].kind).toBe("ask_user");
98
+ expect(msgs[0].id).toBe("ask-req-42");
99
+ // Re-emit of the same request must not stack a second card.
100
+ msgs = reduceMessagesForEvent(msgs, event);
101
+ expect(msgs).toHaveLength(1);
102
+ });
103
+
104
+ it("submit resolves with the right requestId + trimmed answer", () => {
105
+ const v = toAskUserView(event);
106
+ expect(resolveAskUserSubmission(v, " B ")).toEqual({ requestId: "req-42", answer: "B" });
107
+ // Empty / closed inputs do not submit.
108
+ expect(resolveAskUserSubmission(v, " ")).toBeNull();
109
+ expect(resolveAskUserSubmission(v, "B", { answered: true })).toBeNull();
110
+ expect(resolveAskUserSubmission(v, "B", { timedOut: true })).toBeNull();
111
+ });
112
+
113
+ it("a user_input_response resolves the matching card by requestId", () => {
114
+ const cards = reduceMessagesForEvent([], event);
115
+ const resolved = reduceMessagesForEvent(cards, {
116
+ type: "user_input_response",
117
+ requestId: "req-42",
118
+ answer: "A",
119
+ } as WebSocketEvent);
120
+ expect(resolved[0].askUser?.answer).toBe("A");
121
+ expect(isAskUserOpen(resolved[0].askUser!)).toBe(false);
122
+ // A non-matching requestId leaves the card open.
123
+ const other = reduceMessagesForEvent(cards, {
124
+ type: "user_input_response",
125
+ requestId: "nope",
126
+ answer: "X",
127
+ } as WebSocketEvent);
128
+ expect(other[0].askUser?.answer).toBeUndefined();
129
+ expect(isAskUserOpen(other[0].askUser!)).toBe(true);
130
+ });
131
+ });
132
+
133
+ describe("auto_retry mapping + detection + countdown", () => {
134
+ const retryEvent = {
135
+ type: "agent_status_update",
136
+ agentName: "principal",
137
+ status: "retrying",
138
+ attempt: 2,
139
+ maxAttempts: 5,
140
+ delayMs: 3000,
141
+ reason: "rate limited",
142
+ } as WebSocketEvent;
143
+
144
+ it("detects auto-retry status (and only that)", () => {
145
+ expect(isAutoRetryStatus(retryEvent)).toBe(true);
146
+ expect(isAutoRetryStatus({ type: "agent_status_update", status: "running" } as WebSocketEvent)).toBe(false);
147
+ expect(isAutoRetryStatus({ type: "agent_status_update", autoRetry: { attempt: 1 } } as unknown as WebSocketEvent)).toBe(true);
148
+ expect(isAutoRetryStatus({ type: "system_message", level: "info", message: "x" } as WebSocketEvent)).toBe(false);
149
+ });
150
+
151
+ it("maps attempt / maxAttempts / delayMs", () => {
152
+ const v = toAutoRetryView(retryEvent);
153
+ expect(v.attempt).toBe(2);
154
+ expect(v.maxAttempts).toBe(5);
155
+ expect(v.delayMs).toBe(3000);
156
+ expect(v.reason).toBe("rate limited");
157
+ });
158
+
159
+ it("computes ceil-seconds countdown", () => {
160
+ expect(autoRetryCountdownSeconds({ attempt: 1, maxAttempts: 1, delayMs: 3000 })).toBe(3);
161
+ expect(autoRetryCountdownSeconds({ attempt: 1, maxAttempts: 1, delayMs: 2500 })).toBe(3);
162
+ expect(autoRetryCountdownSeconds({ attempt: 1, maxAttempts: 1, delayMs: 0 })).toBe(0);
163
+ });
164
+
165
+ it("reduces a retrying status into an auto_retry ChatMessage", () => {
166
+ const out = reduceMessagesForEvent([], retryEvent);
167
+ expect(out).toHaveLength(1);
168
+ expect(out[0].kind).toBe("auto_retry");
169
+ expect(out[0].autoRetry?.attempt).toBe(2);
170
+ // A normal status update produces no message.
171
+ expect(reduceMessagesForEvent([], { type: "agent_status_update", status: "running" } as WebSocketEvent)).toHaveLength(0);
172
+ });
173
+ });
174
+
175
+ describe("retry cancel callback wiring", () => {
176
+ it("fires the cancel callback when invoked (host-supplied abort path)", () => {
177
+ // The component delegates cancel to a host callback; we assert that calling
178
+ // the supplied handler triggers exactly once (mirrors PromptComposer wiring
179
+ // of onRetryCancel -> interruptCurrent).
180
+ let cancelled = 0;
181
+ const onCancel = () => { cancelled += 1; };
182
+ onCancel();
183
+ expect(cancelled).toBe(1);
184
+ });
185
+ });
186
+
187
+ // Sanity: the reducer leaves an unrelated message list untouched for unknown events.
188
+ describe("reducer passthrough", () => {
189
+ it("ignores unknown event types", () => {
190
+ const existing: ChatMessage[] = [
191
+ { id: "1", role: "user", content: "hi", createdAt: new Date().toISOString() },
192
+ ];
193
+ expect(reduceMessagesForEvent(existing, { type: "WHATEVER" } as WebSocketEvent)).toBe(existing);
194
+ });
195
+ });
196
+
197
+ // Regression: a START with no matching END leaves a message stuck at
198
+ // streaming:true, so the activity group shows "智能体思考中" forever. The run
199
+ // terminators (RUN_FINISHED / RUN_ERROR) must sweep dangling streaming flags.
200
+ describe("run terminators clear dangling streaming flags", () => {
201
+ const open = (agent: string): ChatMessage => ({
202
+ id: `m-${agent}`,
203
+ role: "assistant",
204
+ content: "partial…",
205
+ createdAt: new Date().toISOString(),
206
+ agent,
207
+ streaming: true,
208
+ kind: "text",
209
+ });
210
+
211
+ it("RUN_FINISHED clears streaming on the run agent's messages", () => {
212
+ const out = reduceMessagesForEvent([open("principal")], {
213
+ type: "RUN_FINISHED",
214
+ agentName: "principal",
215
+ } as WebSocketEvent);
216
+ expect(out[0]!.streaming).toBe(false);
217
+ });
218
+
219
+ it("RUN_FINISHED leaves OTHER agents' messages streaming (multi-agent isolation)", () => {
220
+ const out = reduceMessagesForEvent([open("worker")], {
221
+ type: "RUN_FINISHED",
222
+ agentName: "principal",
223
+ } as WebSocketEvent);
224
+ expect(out[0]!.streaming).toBe(true);
225
+ });
226
+
227
+ it("RUN_ERROR clears streaming AND appends an error message", () => {
228
+ const out = reduceMessagesForEvent([open("principal")], {
229
+ type: "RUN_ERROR",
230
+ agentName: "principal",
231
+ message: "boom",
232
+ } as WebSocketEvent);
233
+ expect(out[0]!.streaming).toBe(false);
234
+ expect(out.some((m) => m.kind === "error" && m.content === "boom")).toBe(true);
235
+ });
236
+ });
@@ -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
+ });
@@ -0,0 +1,123 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import { CheckCircle2, HelpCircle } from "lucide-react";
3
+ import type { AskUserView } from "../../contracts/backend";
4
+ import { resolveAskUserSubmission } from "../../contexts/newUiEvents";
5
+ import { useT } from "../../i18n/useT";
6
+
7
+ interface AskUserCardProps {
8
+ view: AskUserView;
9
+ /** Submit the chosen/typed answer back through the send path. */
10
+ onSubmit: (requestId: string, answer: string) => void;
11
+ }
12
+
13
+ /**
14
+ * 修正6 — ask_user interaction card. Renders a `user_input_request` as an
15
+ * interactive card: option buttons and/or a free-text input. On submit it
16
+ * fires `onSubmit(requestId, answer)` so the host can post a
17
+ * user_input_response. Supports an optional countdown when `timeoutSec` is set.
18
+ */
19
+ export function AskUserCard({ view, onSubmit }: AskUserCardProps) {
20
+ const t = useT();
21
+ const [freeText, setFreeText] = useState("");
22
+ const answered = view.answer !== undefined;
23
+
24
+ // Optional countdown. Only ticks while unanswered and a timeout is given.
25
+ const initialSec = useMemo(
26
+ () => (typeof view.timeoutSec === "number" && view.timeoutSec > 0 ? Math.floor(view.timeoutSec) : null),
27
+ [view.timeoutSec],
28
+ );
29
+ const [secondsLeft, setSecondsLeft] = useState<number | null>(initialSec);
30
+
31
+ useEffect(() => {
32
+ if (answered || initialSec === null) return;
33
+ setSecondsLeft(initialSec);
34
+ const id = window.setInterval(() => {
35
+ setSecondsLeft((prev) => {
36
+ if (prev === null) return prev;
37
+ if (prev <= 1) {
38
+ window.clearInterval(id);
39
+ return 0;
40
+ }
41
+ return prev - 1;
42
+ });
43
+ }, 1000);
44
+ return () => window.clearInterval(id);
45
+ }, [answered, initialSec]);
46
+
47
+ const timedOut = secondsLeft === 0;
48
+ const disabled = answered || timedOut;
49
+
50
+ const submit = (answer: string) => {
51
+ const resolved = resolveAskUserSubmission(view, answer, { answered, timedOut });
52
+ if (!resolved) return;
53
+ onSubmit(resolved.requestId, resolved.answer);
54
+ };
55
+
56
+ if (answered) {
57
+ return (
58
+ <div className="ask-user ask-user--answered" data-testid="ask-user" data-request-id={view.requestId}>
59
+ <div className="ask-user__head">
60
+ <CheckCircle2 size={15} className="ask-user__icon" aria-hidden="true" />
61
+ <span className="ask-user__title">{view.agent}</span>
62
+ </div>
63
+ <p className="ask-user__question">{view.question}</p>
64
+ <p className="ask-user__answer">{t("chat.ask.answered", { answer: view.answer ?? "" })}</p>
65
+ </div>
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div className="ask-user" data-testid="ask-user" data-request-id={view.requestId}>
71
+ <div className="ask-user__head">
72
+ <HelpCircle size={15} className="ask-user__icon" aria-hidden="true" />
73
+ <span className="ask-user__title">{t("chat.ask.title")}</span>
74
+ <span className="ask-user__agent">{view.agent}</span>
75
+ {secondsLeft !== null ? (
76
+ <span className="ask-user__timer">
77
+ {timedOut ? t("chat.ask.timedOut") : t("chat.ask.timeoutLeft", { sec: secondsLeft })}
78
+ </span>
79
+ ) : null}
80
+ </div>
81
+ <p className="ask-user__question">{view.question}</p>
82
+
83
+ {view.options && view.options.length > 0 ? (
84
+ <div className="ask-user__options">
85
+ {view.options.map((option) => (
86
+ <button
87
+ key={option}
88
+ type="button"
89
+ className="ask-user__option"
90
+ disabled={disabled}
91
+ onClick={() => submit(option)}
92
+ >
93
+ {option}
94
+ </button>
95
+ ))}
96
+ </div>
97
+ ) : null}
98
+
99
+ {view.allowFreeText || !view.options || view.options.length === 0 ? (
100
+ <form
101
+ className="ask-user__free"
102
+ onSubmit={(e) => {
103
+ e.preventDefault();
104
+ submit(freeText);
105
+ }}
106
+ >
107
+ <input
108
+ className="ask-user__input"
109
+ type="text"
110
+ value={freeText}
111
+ disabled={disabled}
112
+ placeholder={t("chat.ask.freeTextPlaceholder")}
113
+ onChange={(e) => setFreeText(e.target.value)}
114
+ aria-label={view.question}
115
+ />
116
+ <button type="submit" className="ask-user__submit" disabled={disabled || freeText.trim() === ""}>
117
+ {t("chat.ask.submit")}
118
+ </button>
119
+ </form>
120
+ ) : null}
121
+ </div>
122
+ );
123
+ }