@hellcoder/companion 0.96.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.
Files changed (242) hide show
  1. package/bin/cli.ts +168 -0
  2. package/bin/ctl.ts +528 -0
  3. package/bin/generate-token.ts +28 -0
  4. package/dist/apple-touch-icon.png +0 -0
  5. package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
  6. package/dist/assets/CronManager-EGwLJONv.js +1 -0
  7. package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
  8. package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
  9. package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
  10. package/dist/assets/Playground-BV3k0RbV.js +109 -0
  11. package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
  12. package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
  13. package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
  14. package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
  15. package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
  16. package/dist/assets/index-BhUa1e6X.css +1 -0
  17. package/dist/assets/index-DkqeP-R9.js +134 -0
  18. package/dist/assets/sw-register-BibwRdvC.js +1 -0
  19. package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
  20. package/dist/favicon.svg +8 -0
  21. package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
  22. package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
  23. package/dist/icon-192.png +0 -0
  24. package/dist/icon-512.png +0 -0
  25. package/dist/index.html +20 -0
  26. package/dist/logo-codex.svg +14 -0
  27. package/dist/logo-docker.svg +4 -0
  28. package/dist/logo.svg +14 -0
  29. package/dist/manifest.json +24 -0
  30. package/dist/sw.js +2 -0
  31. package/package.json +104 -0
  32. package/server/agent-cron-migrator.test.ts +610 -0
  33. package/server/agent-cron-migrator.ts +85 -0
  34. package/server/agent-executor.test.ts +1108 -0
  35. package/server/agent-executor.ts +346 -0
  36. package/server/agent-store.test.ts +588 -0
  37. package/server/agent-store.ts +185 -0
  38. package/server/agent-types.ts +138 -0
  39. package/server/ai-validation-settings.test.ts +128 -0
  40. package/server/ai-validation-settings.ts +35 -0
  41. package/server/ai-validator.test.ts +387 -0
  42. package/server/ai-validator.ts +271 -0
  43. package/server/auth-manager.test.ts +83 -0
  44. package/server/auth-manager.ts +150 -0
  45. package/server/auto-namer.test.ts +252 -0
  46. package/server/auto-namer.ts +78 -0
  47. package/server/backend-adapter.test.ts +38 -0
  48. package/server/backend-adapter.ts +54 -0
  49. package/server/cache-headers.test.ts +98 -0
  50. package/server/cache-headers.ts +61 -0
  51. package/server/claude-adapter.test.ts +1363 -0
  52. package/server/claude-adapter.ts +889 -0
  53. package/server/claude-container-auth.test.ts +44 -0
  54. package/server/claude-container-auth.ts +30 -0
  55. package/server/claude-protocol-contract.test.ts +71 -0
  56. package/server/claude-protocol-drift.test.ts +78 -0
  57. package/server/claude-session-discovery.test.ts +132 -0
  58. package/server/claude-session-discovery.ts +157 -0
  59. package/server/claude-session-history.test.ts +158 -0
  60. package/server/claude-session-history.ts +410 -0
  61. package/server/cli-launcher.test.ts +1343 -0
  62. package/server/cli-launcher.ts +1298 -0
  63. package/server/cli.test.ts +16 -0
  64. package/server/codex-adapter.test.ts +5545 -0
  65. package/server/codex-adapter.ts +3062 -0
  66. package/server/codex-container-auth.test.ts +50 -0
  67. package/server/codex-container-auth.ts +24 -0
  68. package/server/codex-home.test.ts +61 -0
  69. package/server/codex-home.ts +26 -0
  70. package/server/codex-protocol-contract.test.ts +96 -0
  71. package/server/codex-protocol-drift.test.ts +123 -0
  72. package/server/codex-ws-proxy.cjs +226 -0
  73. package/server/commands-discovery.test.ts +179 -0
  74. package/server/commands-discovery.ts +81 -0
  75. package/server/constants.ts +7 -0
  76. package/server/container-manager.test.ts +1211 -0
  77. package/server/container-manager.ts +1053 -0
  78. package/server/cron-scheduler.test.ts +957 -0
  79. package/server/cron-scheduler.ts +243 -0
  80. package/server/cron-store.test.ts +422 -0
  81. package/server/cron-store.ts +148 -0
  82. package/server/cron-types.ts +63 -0
  83. package/server/env-manager.test.ts +268 -0
  84. package/server/env-manager.ts +161 -0
  85. package/server/event-bus-types.ts +64 -0
  86. package/server/event-bus.test.ts +244 -0
  87. package/server/event-bus.ts +124 -0
  88. package/server/execution-store.test.ts +307 -0
  89. package/server/execution-store.ts +170 -0
  90. package/server/fs-utils.ts +15 -0
  91. package/server/git-utils.test.ts +938 -0
  92. package/server/git-utils.ts +421 -0
  93. package/server/github-pr.test.ts +498 -0
  94. package/server/github-pr.ts +379 -0
  95. package/server/image-pull-manager.test.ts +303 -0
  96. package/server/image-pull-manager.ts +279 -0
  97. package/server/index.ts +396 -0
  98. package/server/linear-agent-bridge.test.ts +1157 -0
  99. package/server/linear-agent-bridge.ts +629 -0
  100. package/server/linear-agent.test.ts +473 -0
  101. package/server/linear-agent.ts +479 -0
  102. package/server/linear-cache.test.ts +136 -0
  103. package/server/linear-cache.ts +113 -0
  104. package/server/linear-connections.test.ts +350 -0
  105. package/server/linear-connections.ts +231 -0
  106. package/server/linear-credential-migration.test.ts +337 -0
  107. package/server/linear-credential-migration.ts +63 -0
  108. package/server/linear-oauth-connections-migration.test.ts +268 -0
  109. package/server/linear-oauth-connections.test.ts +365 -0
  110. package/server/linear-oauth-connections.ts +294 -0
  111. package/server/linear-project-manager.test.ts +162 -0
  112. package/server/linear-project-manager.ts +111 -0
  113. package/server/linear-prompt-builder.test.ts +74 -0
  114. package/server/linear-prompt-builder.ts +61 -0
  115. package/server/linear-staging.test.ts +276 -0
  116. package/server/linear-staging.ts +142 -0
  117. package/server/logger.test.ts +393 -0
  118. package/server/logger.ts +259 -0
  119. package/server/metrics-collector.test.ts +413 -0
  120. package/server/metrics-collector.ts +350 -0
  121. package/server/metrics-types.ts +108 -0
  122. package/server/middleware/managed-auth.test.ts +264 -0
  123. package/server/middleware/managed-auth.ts +195 -0
  124. package/server/novnc-proxy.test.ts +333 -0
  125. package/server/novnc-proxy.ts +99 -0
  126. package/server/path-resolver.test.ts +552 -0
  127. package/server/path-resolver.ts +186 -0
  128. package/server/paths.test.ts +31 -0
  129. package/server/paths.ts +11 -0
  130. package/server/pr-poller.test.ts +191 -0
  131. package/server/pr-poller.ts +162 -0
  132. package/server/prompt-manager.test.ts +211 -0
  133. package/server/prompt-manager.ts +211 -0
  134. package/server/protocol/claude-upstream/README.md +19 -0
  135. package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
  136. package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
  137. package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
  138. package/server/protocol/codex-upstream/README.md +18 -0
  139. package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
  140. package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
  141. package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
  142. package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
  143. package/server/protocol-monitor.ts +50 -0
  144. package/server/recorder.test.ts +454 -0
  145. package/server/recorder.ts +374 -0
  146. package/server/recording-hub/compat-validator.test.ts +150 -0
  147. package/server/recording-hub/compat-validator.ts +284 -0
  148. package/server/recording-hub/diagnostics.test.ts +140 -0
  149. package/server/recording-hub/diagnostics.ts +299 -0
  150. package/server/recording-hub/hub-config.test.ts +44 -0
  151. package/server/recording-hub/hub-config.ts +19 -0
  152. package/server/recording-hub/hub-routes.test.ts +417 -0
  153. package/server/recording-hub/hub-routes.ts +236 -0
  154. package/server/recording-hub/hub-store.test.ts +262 -0
  155. package/server/recording-hub/hub-store.ts +265 -0
  156. package/server/recording-hub/replay-adapter.test.ts +294 -0
  157. package/server/recording-hub/replay-adapter.ts +207 -0
  158. package/server/relay-client.test.ts +337 -0
  159. package/server/relay-client.ts +320 -0
  160. package/server/replay.test.ts +200 -0
  161. package/server/replay.ts +78 -0
  162. package/server/routes/agent-routes.test.ts +1400 -0
  163. package/server/routes/agent-routes.ts +409 -0
  164. package/server/routes/cron-routes.test.ts +881 -0
  165. package/server/routes/cron-routes.ts +103 -0
  166. package/server/routes/env-routes.test.ts +383 -0
  167. package/server/routes/env-routes.ts +95 -0
  168. package/server/routes/fs-routes.test.ts +1198 -0
  169. package/server/routes/fs-routes.ts +605 -0
  170. package/server/routes/git-routes.test.ts +813 -0
  171. package/server/routes/git-routes.ts +97 -0
  172. package/server/routes/linear-agent-routes.test.ts +721 -0
  173. package/server/routes/linear-agent-routes.ts +304 -0
  174. package/server/routes/linear-connection-routes.test.ts +927 -0
  175. package/server/routes/linear-connection-routes.ts +244 -0
  176. package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
  177. package/server/routes/linear-oauth-connection-routes.ts +129 -0
  178. package/server/routes/linear-routes.test.ts +1510 -0
  179. package/server/routes/linear-routes.ts +953 -0
  180. package/server/routes/metrics-routes.test.ts +103 -0
  181. package/server/routes/metrics-routes.ts +13 -0
  182. package/server/routes/prompt-routes.ts +67 -0
  183. package/server/routes/sandbox-routes.test.ts +513 -0
  184. package/server/routes/sandbox-routes.ts +127 -0
  185. package/server/routes/settings-routes.ts +270 -0
  186. package/server/routes/skills-routes.test.ts +690 -0
  187. package/server/routes/skills-routes.ts +100 -0
  188. package/server/routes/system-routes.test.ts +637 -0
  189. package/server/routes/system-routes.ts +228 -0
  190. package/server/routes/tailscale-routes.test.ts +176 -0
  191. package/server/routes/tailscale-routes.ts +22 -0
  192. package/server/routes.test.ts +4655 -0
  193. package/server/routes.ts +1277 -0
  194. package/server/sandbox-manager.test.ts +378 -0
  195. package/server/sandbox-manager.ts +168 -0
  196. package/server/service.test.ts +1419 -0
  197. package/server/service.ts +718 -0
  198. package/server/session-creation-service.test.ts +661 -0
  199. package/server/session-creation-service.ts +473 -0
  200. package/server/session-git-info.ts +104 -0
  201. package/server/session-linear-issues.test.ts +118 -0
  202. package/server/session-linear-issues.ts +88 -0
  203. package/server/session-names.test.ts +94 -0
  204. package/server/session-names.ts +67 -0
  205. package/server/session-orchestrator.test.ts +1784 -0
  206. package/server/session-orchestrator.ts +973 -0
  207. package/server/session-state-machine.test.ts +606 -0
  208. package/server/session-state-machine.ts +207 -0
  209. package/server/session-store.test.ts +290 -0
  210. package/server/session-store.ts +146 -0
  211. package/server/session-types.ts +509 -0
  212. package/server/settings-manager.test.ts +275 -0
  213. package/server/settings-manager.ts +173 -0
  214. package/server/tailscale-manager.test.ts +553 -0
  215. package/server/tailscale-manager.ts +451 -0
  216. package/server/terminal-manager.ts +240 -0
  217. package/server/update-checker.test.ts +306 -0
  218. package/server/update-checker.ts +197 -0
  219. package/server/usage-limits.test.ts +536 -0
  220. package/server/usage-limits.ts +225 -0
  221. package/server/worktree-tracker.test.ts +243 -0
  222. package/server/worktree-tracker.ts +84 -0
  223. package/server/ws-auth.test.ts +59 -0
  224. package/server/ws-auth.ts +41 -0
  225. package/server/ws-bridge-browser-ingest.test.ts +272 -0
  226. package/server/ws-bridge-browser-ingest.ts +72 -0
  227. package/server/ws-bridge-browser.ts +112 -0
  228. package/server/ws-bridge-cli-ingest.test.ts +302 -0
  229. package/server/ws-bridge-cli-ingest.ts +81 -0
  230. package/server/ws-bridge-codex.test.ts +1837 -0
  231. package/server/ws-bridge-codex.ts +266 -0
  232. package/server/ws-bridge-controls.test.ts +124 -0
  233. package/server/ws-bridge-controls.ts +20 -0
  234. package/server/ws-bridge-persist.test.ts +296 -0
  235. package/server/ws-bridge-persist.ts +66 -0
  236. package/server/ws-bridge-publish.test.ts +234 -0
  237. package/server/ws-bridge-publish.ts +79 -0
  238. package/server/ws-bridge-replay.test.ts +44 -0
  239. package/server/ws-bridge-replay.ts +61 -0
  240. package/server/ws-bridge-types.ts +106 -0
  241. package/server/ws-bridge.test.ts +4777 -0
  242. package/server/ws-bridge.ts +1279 -0
@@ -0,0 +1,1363 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach, afterAll } from "vitest";
2
+
3
+ // ─── Stub Bun global for vitest (runs under Node, not Bun) ──────────────────
4
+ // Bun.hash is used by isDuplicateCLIMessage in ws-bridge-cli-ingest.ts.
5
+ // A simple string hash is sufficient for test determinism.
6
+ if (typeof globalThis.Bun === "undefined") {
7
+ (globalThis as any).Bun = {
8
+ hash(input: string | Uint8Array): number {
9
+ const s = typeof input === "string" ? input : new TextDecoder().decode(input);
10
+ let h = 0;
11
+ for (let i = 0; i < s.length; i++) {
12
+ h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
13
+ }
14
+ return h >>> 0; // unsigned 32-bit
15
+ },
16
+ };
17
+ }
18
+
19
+ // Mock node:crypto to return deterministic UUIDs for control_request IDs
20
+ let uuidCounter = 0;
21
+ vi.mock("node:crypto", () => ({
22
+ randomUUID: () => `test-uuid-${uuidCounter++}`,
23
+ }));
24
+
25
+ // Mock settings-manager to prevent real file system reads
26
+ vi.mock("./settings-manager.js", () => ({
27
+ getSettings: () => ({
28
+ aiValidationEnabled: false,
29
+ aiValidationAutoApprove: false,
30
+ aiValidationAutoDeny: false,
31
+ anthropicApiKey: "",
32
+ }),
33
+ DEFAULT_ANTHROPIC_MODEL: "claude-sonnet-4-6",
34
+ }));
35
+
36
+ import { ClaudeAdapter } from "./claude-adapter.js";
37
+ import { log } from "./logger.js";
38
+
39
+ // ─── Mock socket factory ────────────────────────────────────────────────────
40
+
41
+ /** Creates a minimal mock ServerWebSocket<SocketData> for CLI connections. */
42
+ function createMockSocket(sessionId: string) {
43
+ return {
44
+ data: { kind: "cli" as const, sessionId },
45
+ send: vi.fn(),
46
+ close: vi.fn(),
47
+ readyState: 1,
48
+ } as any;
49
+ }
50
+
51
+ // ─── Helper: build NDJSON CLI messages ──────────────────────────────────────
52
+
53
+ function makeInitMsg(overrides: Record<string, unknown> = {}) {
54
+ return JSON.stringify({
55
+ type: "system",
56
+ subtype: "init",
57
+ session_id: "cli-123",
58
+ model: "claude-sonnet-4-6",
59
+ cwd: "/test",
60
+ tools: ["Bash", "Read"],
61
+ permissionMode: "default",
62
+ claude_code_version: "1.0",
63
+ mcp_servers: [],
64
+ agents: [],
65
+ slash_commands: [],
66
+ skills: [],
67
+ output_style: "normal",
68
+ uuid: "uuid-1",
69
+ apiKeySource: "env",
70
+ ...overrides,
71
+ });
72
+ }
73
+
74
+ function makeAssistantMsg(overrides: Record<string, unknown> = {}) {
75
+ return JSON.stringify({
76
+ type: "assistant",
77
+ message: {
78
+ id: "msg-1",
79
+ type: "message",
80
+ role: "assistant",
81
+ model: "claude-sonnet-4-6",
82
+ content: [{ type: "text", text: "Hello world" }],
83
+ stop_reason: "end_turn",
84
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
85
+ },
86
+ parent_tool_use_id: null,
87
+ uuid: "asst-uuid-1",
88
+ session_id: "cli-123",
89
+ ...overrides,
90
+ });
91
+ }
92
+
93
+ function makeResultMsg(overrides: Record<string, unknown> = {}) {
94
+ return JSON.stringify({
95
+ type: "result",
96
+ subtype: "success",
97
+ is_error: false,
98
+ result: "Done",
99
+ duration_ms: 1000,
100
+ duration_api_ms: 800,
101
+ num_turns: 1,
102
+ total_cost_usd: 0.01,
103
+ stop_reason: "end_turn",
104
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
105
+ context_used_percent: 5,
106
+ uuid: "result-uuid-1",
107
+ session_id: "cli-123",
108
+ ...overrides,
109
+ });
110
+ }
111
+
112
+ function makeStreamEventMsg(overrides: Record<string, unknown> = {}) {
113
+ return JSON.stringify({
114
+ type: "stream_event",
115
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hi" } },
116
+ parent_tool_use_id: null,
117
+ uuid: "stream-uuid-1",
118
+ session_id: "cli-123",
119
+ ...overrides,
120
+ });
121
+ }
122
+
123
+ function makeControlRequestMsg(overrides: Record<string, unknown> = {}) {
124
+ return JSON.stringify({
125
+ type: "control_request",
126
+ request_id: "ctrl-req-1",
127
+ request: {
128
+ subtype: "can_use_tool",
129
+ tool_name: "Bash",
130
+ input: { command: "ls" },
131
+ description: "List files",
132
+ tool_use_id: "tu-1",
133
+ ...((overrides as any).request ?? {}),
134
+ },
135
+ ...overrides,
136
+ // Restore request if it was overridden
137
+ ...(overrides.request ? { request: overrides.request } : {}),
138
+ });
139
+ }
140
+
141
+ function makeToolProgressMsg(overrides: Record<string, unknown> = {}) {
142
+ return JSON.stringify({
143
+ type: "tool_progress",
144
+ tool_use_id: "tu-1",
145
+ tool_name: "Bash",
146
+ parent_tool_use_id: null,
147
+ elapsed_time_seconds: 2,
148
+ uuid: "tp-uuid-1",
149
+ session_id: "cli-123",
150
+ ...overrides,
151
+ });
152
+ }
153
+
154
+ function makeAuthStatusMsg(overrides: Record<string, unknown> = {}) {
155
+ return JSON.stringify({
156
+ type: "auth_status",
157
+ isAuthenticating: true,
158
+ output: ["Authenticating..."],
159
+ uuid: "auth-uuid-1",
160
+ session_id: "cli-123",
161
+ ...overrides,
162
+ });
163
+ }
164
+
165
+ function makeKeepAliveMsg() {
166
+ return JSON.stringify({ type: "keep_alive" });
167
+ }
168
+
169
+ function makeSystemStatusMsg(overrides: Record<string, unknown> = {}) {
170
+ return JSON.stringify({
171
+ type: "system",
172
+ subtype: "status",
173
+ status: "compacting",
174
+ uuid: "status-uuid-1",
175
+ session_id: "cli-123",
176
+ ...overrides,
177
+ });
178
+ }
179
+
180
+ // ─── Test suite ─────────────────────────────────────────────────────────────
181
+
182
+ let adapter: ClaudeAdapter;
183
+ let browserMessageCb: ReturnType<typeof vi.fn>;
184
+ let sessionMetaCb: ReturnType<typeof vi.fn>;
185
+ let disconnectCb: ReturnType<typeof vi.fn>;
186
+ let onActivityUpdate: ReturnType<typeof vi.fn>;
187
+
188
+ beforeEach(() => {
189
+ uuidCounter = 0;
190
+ onActivityUpdate = vi.fn();
191
+ adapter = new ClaudeAdapter("sess-1", { onActivityUpdate: onActivityUpdate as unknown as () => void });
192
+ browserMessageCb = vi.fn();
193
+ sessionMetaCb = vi.fn();
194
+ disconnectCb = vi.fn();
195
+ adapter.onBrowserMessage(browserMessageCb as any);
196
+ adapter.onSessionMeta(sessionMetaCb as any);
197
+ adapter.onDisconnect(disconnectCb as unknown as () => void);
198
+
199
+ // Suppress console output to prevent Vitest EnvironmentTeardownError
200
+ vi.spyOn(console, "log").mockImplementation(() => {});
201
+ vi.spyOn(console, "warn").mockImplementation(() => {});
202
+ vi.spyOn(console, "error").mockImplementation(() => {});
203
+ });
204
+
205
+ afterEach(() => {
206
+ vi.restoreAllMocks();
207
+ });
208
+
209
+ // Prevent "Closing rpc while onUserConsoleLog was pending" during teardown
210
+ afterAll(() => {
211
+ const noop = () => {};
212
+ console.log = noop;
213
+ console.warn = noop;
214
+ console.error = noop;
215
+ });
216
+
217
+ describe("Protocol drift handling", () => {
218
+ it("logs and surfaces unknown Claude message types", () => {
219
+ const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
220
+
221
+ adapter.handleRawMessage(`${JSON.stringify({ type: "brand_new_message", payload: { x: 1 } })}\n`);
222
+
223
+ expect(spy).toHaveBeenCalledWith(
224
+ "protocol-monitor",
225
+ "Backend protocol drift detected",
226
+ expect.objectContaining({
227
+ backend: "claude",
228
+ sessionId: "sess-1",
229
+ direction: "incoming",
230
+ messageKind: "message",
231
+ messageName: "brand_new_message",
232
+ }),
233
+ );
234
+ expect(browserMessageCb).toHaveBeenCalledWith(
235
+ expect.objectContaining({
236
+ type: "error",
237
+ message: expect.stringContaining("brand_new_message"),
238
+ }),
239
+ );
240
+
241
+ spy.mockRestore();
242
+ });
243
+
244
+ it("deduplicates repeated Claude parse-error drift logs", () => {
245
+ const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
246
+
247
+ adapter.handleRawMessage("not-json\nstill-not-json\n");
248
+
249
+ expect(spy).toHaveBeenCalledTimes(1);
250
+ expect(spy).toHaveBeenCalledWith(
251
+ "protocol-monitor",
252
+ "Backend protocol drift detected",
253
+ expect.objectContaining({
254
+ backend: "claude",
255
+ sessionId: "sess-1",
256
+ messageKind: "parse_error",
257
+ messageName: "ndjson",
258
+ }),
259
+ );
260
+
261
+ spy.mockRestore();
262
+ });
263
+
264
+ it("surfaces parse errors to the browser as error messages", () => {
265
+ // Parse errors should notify the browser so the user sees something
266
+ // instead of a silent failure.
267
+ const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
268
+
269
+ adapter.handleRawMessage("{{broken-json}}\n");
270
+
271
+ expect(browserMessageCb).toHaveBeenCalledWith(
272
+ expect.objectContaining({
273
+ type: "error",
274
+ message: expect.stringContaining("parse_error"),
275
+ }),
276
+ );
277
+
278
+ spy.mockRestore();
279
+ });
280
+ });
281
+
282
+ // ─── Known non-standard CLI message types ────────────────────────────────────
283
+
284
+ describe("Known non-standard CLI message types", () => {
285
+ it("rate_limit_event is silently consumed without protocol drift warning", () => {
286
+ // The CLI sends rate_limit_event messages with throttle/allow status.
287
+ // These should be silently consumed and NOT trigger protocol drift logs.
288
+ const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
289
+ const ws = createMockSocket("sess-1");
290
+ adapter.attachWebSocket(ws);
291
+
292
+ adapter.handleRawMessage(
293
+ JSON.stringify({
294
+ type: "rate_limit_event",
295
+ rate_limit_info: { is_rate_limited: false, resets_at: null },
296
+ uuid: "rl-uuid-1",
297
+ }) + "\n",
298
+ );
299
+
300
+ // Should NOT produce a protocol drift warning
301
+ expect(spy).not.toHaveBeenCalledWith(
302
+ "protocol-monitor",
303
+ "Backend protocol drift detected",
304
+ expect.anything(),
305
+ );
306
+ // Should NOT emit an error to the browser
307
+ expect(browserMessageCb).not.toHaveBeenCalledWith(
308
+ expect.objectContaining({ type: "error" }),
309
+ );
310
+
311
+ spy.mockRestore();
312
+ });
313
+
314
+ it("user echo with plain string content is silently dropped to avoid duplicates", () => {
315
+ // Plain string echoes are duplicates of messages the browser already has
316
+ // (the browser sends user_message → ws-bridge stores it → CLI echoes it
317
+ // back). Silently drop them to prevent duplicate messages in the UI.
318
+ const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
319
+ const ws = createMockSocket("sess-1");
320
+ adapter.attachWebSocket(ws);
321
+
322
+ adapter.handleRawMessage(
323
+ JSON.stringify({
324
+ type: "user",
325
+ message: { role: "user", content: "Hello from browser" },
326
+ uuid: "user-echo-1",
327
+ session_id: "cli-123",
328
+ }) + "\n",
329
+ );
330
+
331
+ // Should NOT produce a protocol drift warning
332
+ expect(spy).not.toHaveBeenCalledWith(
333
+ "protocol-monitor",
334
+ "Backend protocol drift detected",
335
+ expect.anything(),
336
+ );
337
+ // Should NOT emit to browser — plain string echoes are dropped
338
+ expect(browserMessageCb).not.toHaveBeenCalledWith(
339
+ expect.objectContaining({ type: "user_message" }),
340
+ );
341
+
342
+ spy.mockRestore();
343
+ });
344
+
345
+ it("user echo with non-string content is silently dropped", () => {
346
+ // User echo messages with tool_result arrays are redundant — the tool
347
+ // results are already present in the subsequent assistant message content
348
+ // blocks. Forwarding them caused raw JSON text bubbles in the chat UI.
349
+ const ws = createMockSocket("sess-1");
350
+ adapter.attachWebSocket(ws);
351
+
352
+ const complexContent = [
353
+ { type: "tool_result", tool_use_id: "t1", content: "result" },
354
+ ];
355
+ adapter.handleRawMessage(
356
+ JSON.stringify({
357
+ type: "user",
358
+ message: { role: "user", content: complexContent },
359
+ uuid: "user-echo-2",
360
+ session_id: "cli-123",
361
+ }) + "\n",
362
+ );
363
+
364
+ // Should NOT emit a user_message to the browser
365
+ expect(browserMessageCb).not.toHaveBeenCalledWith(
366
+ expect.objectContaining({ type: "user_message" }),
367
+ );
368
+ });
369
+ });
370
+
371
+ // ─── Connection lifecycle ───────────────────────────────────────────────────
372
+
373
+ describe("Connection lifecycle", () => {
374
+ it("isConnected() returns false initially when no WebSocket is attached", () => {
375
+ // A freshly created adapter has no CLI socket, so it should not be connected.
376
+ expect(adapter.isConnected()).toBe(false);
377
+ });
378
+
379
+ it("attachWebSocket stores the socket and makes isConnected() return true", () => {
380
+ // Attaching a mock WebSocket should mark the adapter as connected.
381
+ const ws = createMockSocket("sess-1");
382
+ adapter.attachWebSocket(ws);
383
+ expect(adapter.isConnected()).toBe(true);
384
+ });
385
+
386
+ it("detachWebSocket clears the socket and calls disconnectCb", () => {
387
+ // Detaching the current socket should clear the connection and notify
388
+ // the bridge via the disconnect callback.
389
+ const ws = createMockSocket("sess-1");
390
+ adapter.attachWebSocket(ws);
391
+ expect(adapter.isConnected()).toBe(true);
392
+
393
+ adapter.detachWebSocket(ws);
394
+ expect(adapter.isConnected()).toBe(false);
395
+ expect(disconnectCb).toHaveBeenCalledOnce();
396
+ });
397
+
398
+ it("detachWebSocket with a stale socket (different ws) does nothing", () => {
399
+ // If a new WebSocket replaced an old one, closing the old one should
400
+ // NOT clear the current connection or trigger the disconnect callback.
401
+ const ws1 = createMockSocket("sess-1");
402
+ const ws2 = createMockSocket("sess-1");
403
+ adapter.attachWebSocket(ws1);
404
+
405
+ // Replace with ws2
406
+ adapter.attachWebSocket(ws2);
407
+
408
+ // Detach ws1 (stale) — should be ignored
409
+ adapter.detachWebSocket(ws1);
410
+ expect(adapter.isConnected()).toBe(true);
411
+ expect(disconnectCb).not.toHaveBeenCalled();
412
+ });
413
+
414
+ it("disconnect() closes the socket and clears the connection", async () => {
415
+ // disconnect() should call close() on the socket and clear it.
416
+ const ws = createMockSocket("sess-1");
417
+ adapter.attachWebSocket(ws);
418
+
419
+ await adapter.disconnect();
420
+ expect(ws.close).toHaveBeenCalledOnce();
421
+ expect(adapter.isConnected()).toBe(false);
422
+ });
423
+
424
+ it("disconnect() with no socket is a no-op", async () => {
425
+ // Calling disconnect when there's no socket should not throw.
426
+ await adapter.disconnect();
427
+ expect(adapter.isConnected()).toBe(false);
428
+ });
429
+
430
+ it("handleTransportClose() clears socket without calling disconnectCb", () => {
431
+ // handleTransportClose is used when a WS proxy drops — it clears the
432
+ // socket reference without triggering the disconnect callback so the
433
+ // CLI can reconnect.
434
+ const ws = createMockSocket("sess-1");
435
+ adapter.attachWebSocket(ws);
436
+
437
+ adapter.handleTransportClose();
438
+ expect(adapter.isConnected()).toBe(false);
439
+ expect(disconnectCb).not.toHaveBeenCalled();
440
+ });
441
+ });
442
+
443
+ // ─── Message queuing ────────────────────────────────────────────────────────
444
+
445
+ describe("Message queuing", () => {
446
+ it("messages sent via send() before WebSocket connects are queued", () => {
447
+ // Without an attached WebSocket, outgoing messages should be queued
448
+ // and not lost. We verify by attaching a socket later and checking
449
+ // that the queued messages are flushed.
450
+ const result = adapter.send({ type: "user_message", content: "hello" });
451
+ expect(result).toBe(true);
452
+ // No socket attached — nothing was sent yet.
453
+ });
454
+
455
+ it("queued messages are flushed when attachWebSocket is called", () => {
456
+ // Send messages while disconnected, then verify they are delivered
457
+ // when the WebSocket attaches.
458
+ adapter.send({ type: "user_message", content: "first" });
459
+ adapter.send({ type: "user_message", content: "second" });
460
+
461
+ const ws = createMockSocket("sess-1");
462
+ adapter.attachWebSocket(ws);
463
+
464
+ // Both queued messages should have been flushed to the socket.
465
+ // Each message results in a send() call with NDJSON + newline.
466
+ expect(ws.send).toHaveBeenCalledTimes(2);
467
+
468
+ // Verify the first message content
469
+ const firstCall = ws.send.mock.calls[0][0] as string;
470
+ const parsed1 = JSON.parse(firstCall.trim());
471
+ expect(parsed1.type).toBe("user");
472
+ expect(parsed1.message.content).toBe("first");
473
+
474
+ // Verify the second message content
475
+ const secondCall = ws.send.mock.calls[1][0] as string;
476
+ const parsed2 = JSON.parse(secondCall.trim());
477
+ expect(parsed2.type).toBe("user");
478
+ expect(parsed2.message.content).toBe("second");
479
+ });
480
+
481
+ it("messages sent after WebSocket connects go directly to the socket", () => {
482
+ // Once a socket is attached, messages should be sent immediately
483
+ // without queuing.
484
+ const ws = createMockSocket("sess-1");
485
+ adapter.attachWebSocket(ws);
486
+
487
+ adapter.send({ type: "user_message", content: "direct" });
488
+
489
+ expect(ws.send).toHaveBeenCalledTimes(1);
490
+ const sent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
491
+ expect(sent.type).toBe("user");
492
+ expect(sent.message.content).toBe("direct");
493
+ });
494
+ });
495
+
496
+ // ─── send() — outgoing message translation ──────────────────────────────────
497
+
498
+ describe("send() — outgoing message translation", () => {
499
+ let ws: ReturnType<typeof createMockSocket>;
500
+
501
+ beforeEach(() => {
502
+ ws = createMockSocket("sess-1");
503
+ adapter.attachWebSocket(ws);
504
+ });
505
+
506
+ /** Helper to parse the last NDJSON sent on the mock socket. */
507
+ function getLastSent(): any {
508
+ const calls = ws.send.mock.calls;
509
+ return JSON.parse((calls[calls.length - 1][0] as string).trim());
510
+ }
511
+
512
+ it("user_message → sends NDJSON with type 'user' and user-role message", () => {
513
+ // A user_message from the browser should be translated into Claude Code's
514
+ // NDJSON format: { type: "user", message: { role: "user", content }, ... }
515
+ adapter.send({ type: "user_message", content: "Hello Claude" });
516
+ const sent = getLastSent();
517
+ expect(sent.type).toBe("user");
518
+ expect(sent.message.role).toBe("user");
519
+ expect(sent.message.content).toBe("Hello Claude");
520
+ expect(sent.parent_tool_use_id).toBeNull();
521
+ expect(sent.session_id).toBe("");
522
+ });
523
+
524
+ it("user_message with session_id passes it through", () => {
525
+ // When a session_id is provided in the user_message, it should be included.
526
+ adapter.send({ type: "user_message", content: "hi", session_id: "sid-1" });
527
+ const sent = getLastSent();
528
+ expect(sent.session_id).toBe("sid-1");
529
+ });
530
+
531
+ it("user_message with images → includes image blocks in content array", () => {
532
+ // Images should be prepended as image blocks before a text block
533
+ // in the content array, following the Claude content block format.
534
+ adapter.send({
535
+ type: "user_message",
536
+ content: "Describe this",
537
+ images: [{ media_type: "image/png", data: "base64data" }],
538
+ });
539
+ const sent = getLastSent();
540
+ expect(sent.type).toBe("user");
541
+ expect(Array.isArray(sent.message.content)).toBe(true);
542
+ expect(sent.message.content).toHaveLength(2);
543
+
544
+ // First block: image
545
+ expect(sent.message.content[0].type).toBe("image");
546
+ expect(sent.message.content[0].source.type).toBe("base64");
547
+ expect(sent.message.content[0].source.media_type).toBe("image/png");
548
+ expect(sent.message.content[0].source.data).toBe("base64data");
549
+
550
+ // Second block: text
551
+ expect(sent.message.content[1].type).toBe("text");
552
+ expect(sent.message.content[1].text).toBe("Describe this");
553
+ });
554
+
555
+ it("permission_response allow → sends correct control_response NDJSON", () => {
556
+ // An "allow" permission response should be translated into a
557
+ // control_response with behavior: "allow" and updatedInput.
558
+ adapter.send({
559
+ type: "permission_response",
560
+ request_id: "req-1",
561
+ behavior: "allow",
562
+ });
563
+ const sent = getLastSent();
564
+ expect(sent.type).toBe("control_response");
565
+ expect(sent.response.subtype).toBe("success");
566
+ expect(sent.response.request_id).toBe("req-1");
567
+ expect(sent.response.response.behavior).toBe("allow");
568
+ expect(sent.response.response.updatedInput).toEqual({});
569
+ });
570
+
571
+ it("permission_response allow with updated_input and updated_permissions", () => {
572
+ // When updated_input and updated_permissions are provided, they should
573
+ // be forwarded in the control_response.
574
+ adapter.send({
575
+ type: "permission_response",
576
+ request_id: "req-2",
577
+ behavior: "allow",
578
+ updated_input: { command: "ls -la" },
579
+ updated_permissions: [{ type: "addRules" as const, rules: [{ toolName: "Bash" }], behavior: "allow" as const, destination: "project" as any }],
580
+ });
581
+ const sent = getLastSent();
582
+ expect(sent.response.response.updatedInput).toEqual({ command: "ls -la" });
583
+ expect(sent.response.response.updatedPermissions).toHaveLength(1);
584
+ });
585
+
586
+ it("permission_response deny → sends control_response NDJSON with deny message", () => {
587
+ // A "deny" permission response should include behavior: "deny" and
588
+ // a message explaining the denial.
589
+ adapter.send({
590
+ type: "permission_response",
591
+ request_id: "req-3",
592
+ behavior: "deny",
593
+ message: "Not allowed",
594
+ });
595
+ const sent = getLastSent();
596
+ expect(sent.type).toBe("control_response");
597
+ expect(sent.response.subtype).toBe("success");
598
+ expect(sent.response.request_id).toBe("req-3");
599
+ expect(sent.response.response.behavior).toBe("deny");
600
+ expect(sent.response.response.message).toBe("Not allowed");
601
+ });
602
+
603
+ it("permission_response deny without explicit message uses default", () => {
604
+ // When no denial message is provided, a default should be used.
605
+ adapter.send({
606
+ type: "permission_response",
607
+ request_id: "req-4",
608
+ behavior: "deny",
609
+ });
610
+ const sent = getLastSent();
611
+ expect(sent.response.response.message).toBe("Denied by user");
612
+ });
613
+
614
+ it("interrupt → sends control_request with subtype 'interrupt'", () => {
615
+ // An interrupt should be translated into a control_request with
616
+ // a deterministic UUID (mocked) and subtype "interrupt".
617
+ adapter.send({ type: "interrupt" });
618
+ const sent = getLastSent();
619
+ expect(sent.type).toBe("control_request");
620
+ expect(sent.request.subtype).toBe("interrupt");
621
+ expect(sent.request_id).toMatch(/^test-uuid-/);
622
+ });
623
+
624
+ it("set_model → sends control_request with subtype 'set_model'", () => {
625
+ // The set_model message should forward the model name in a control_request.
626
+ adapter.send({ type: "set_model", model: "claude-opus-4-6" });
627
+ const sent = getLastSent();
628
+ expect(sent.type).toBe("control_request");
629
+ expect(sent.request.subtype).toBe("set_model");
630
+ expect(sent.request.model).toBe("claude-opus-4-6");
631
+ });
632
+
633
+ it("set_permission_mode → sends control_request with subtype 'set_permission_mode'", () => {
634
+ // The permission mode change should be forwarded to the CLI backend.
635
+ adapter.send({ type: "set_permission_mode", mode: "plan" });
636
+ const sent = getLastSent();
637
+ expect(sent.type).toBe("control_request");
638
+ expect(sent.request.subtype).toBe("set_permission_mode");
639
+ expect(sent.request.mode).toBe("plan");
640
+ });
641
+
642
+ it("set_ai_validation → returns true without sending anything", () => {
643
+ // AI validation state is managed at the bridge level, not forwarded
644
+ // to the CLI. send() should return true (accepted) but not send any data.
645
+ const result = adapter.send({
646
+ type: "set_ai_validation",
647
+ aiValidationEnabled: true,
648
+ });
649
+ expect(result).toBe(true);
650
+ // No message should have been sent to the socket
651
+ expect(ws.send).not.toHaveBeenCalled();
652
+ });
653
+
654
+ it("session_subscribe → returns false (handled at bridge level)", () => {
655
+ // session_subscribe is handled by the bridge, not forwarded to the backend.
656
+ const result = adapter.send({ type: "session_subscribe", last_seq: 0 });
657
+ expect(result).toBe(false);
658
+ expect(ws.send).not.toHaveBeenCalled();
659
+ });
660
+
661
+ it("session_ack → returns false (handled at bridge level)", () => {
662
+ // session_ack is handled by the bridge, not forwarded to the backend.
663
+ const result = adapter.send({ type: "session_ack", last_seq: 5 });
664
+ expect(result).toBe(false);
665
+ expect(ws.send).not.toHaveBeenCalled();
666
+ });
667
+
668
+ it("mcp_get_status → sends control_request with subtype 'mcp_status'", () => {
669
+ // MCP status request should be sent as a control_request and tracked
670
+ // for async response resolution.
671
+ adapter.send({ type: "mcp_get_status" });
672
+ const sent = getLastSent();
673
+ expect(sent.type).toBe("control_request");
674
+ expect(sent.request.subtype).toBe("mcp_status");
675
+ expect(sent.request_id).toMatch(/^test-uuid-/);
676
+ });
677
+
678
+ it("send() returns true for accepted messages", () => {
679
+ // Verify that all accepted message types return true.
680
+ expect(adapter.send({ type: "user_message", content: "hi" })).toBe(true);
681
+ expect(adapter.send({ type: "interrupt" })).toBe(true);
682
+ expect(adapter.send({ type: "set_model", model: "m" })).toBe(true);
683
+ expect(adapter.send({ type: "set_permission_mode", mode: "plan" })).toBe(true);
684
+ expect(adapter.send({ type: "mcp_get_status" })).toBe(true);
685
+ });
686
+ });
687
+
688
+ // ─── handleRawMessage() — incoming CLI message routing ──────────────────────
689
+
690
+ describe("handleRawMessage() — incoming CLI message routing", () => {
691
+ let ws: ReturnType<typeof createMockSocket>;
692
+
693
+ beforeEach(() => {
694
+ ws = createMockSocket("sess-1");
695
+ adapter.attachWebSocket(ws);
696
+ });
697
+
698
+ it("system init → emits sessionMetaCb and browserMessageCb with session_init", () => {
699
+ // A system init message from the CLI should update session metadata
700
+ // (cliSessionId, model, cwd) and broadcast a session_init to browsers.
701
+ adapter.handleRawMessage(makeInitMsg());
702
+
703
+ expect(sessionMetaCb).toHaveBeenCalledOnce();
704
+ expect(sessionMetaCb).toHaveBeenCalledWith({
705
+ cliSessionId: "cli-123",
706
+ model: "claude-sonnet-4-6",
707
+ cwd: "/test",
708
+ });
709
+
710
+ expect(browserMessageCb).toHaveBeenCalledOnce();
711
+ const msg = browserMessageCb.mock.calls[0][0];
712
+ expect(msg.type).toBe("session_init");
713
+ expect(msg.session.session_id).toBe("cli-123");
714
+ expect(msg.session.model).toBe("claude-sonnet-4-6");
715
+ expect(msg.session.cwd).toBe("/test");
716
+ expect(msg.session.tools).toEqual(["Bash", "Read"]);
717
+ expect(msg.session.permissionMode).toBe("default");
718
+ expect(msg.session.claude_code_version).toBe("1.0");
719
+ expect(msg.session.mcp_servers).toEqual([]);
720
+ });
721
+
722
+ it("system status → emits browserMessageCb with status_change", () => {
723
+ // A system status message (e.g. compacting) should be translated to
724
+ // a status_change browser message.
725
+ adapter.handleRawMessage(makeSystemStatusMsg());
726
+
727
+ expect(browserMessageCb).toHaveBeenCalledOnce();
728
+ const msg = browserMessageCb.mock.calls[0][0];
729
+ expect(msg.type).toBe("status_change");
730
+ expect(msg.status).toBe("compacting");
731
+ });
732
+
733
+ it("system status with null status → emits status_change with null", () => {
734
+ // When the CLI sends status: null, it means the status is cleared.
735
+ adapter.handleRawMessage(makeSystemStatusMsg({ status: null }));
736
+
737
+ const msg = browserMessageCb.mock.calls[0][0];
738
+ expect(msg.type).toBe("status_change");
739
+ expect(msg.status).toBeNull();
740
+ });
741
+
742
+ it("assistant → emits browserMessageCb with assistant message including timestamp", () => {
743
+ // An assistant message should be forwarded with its full message payload
744
+ // and a server-assigned timestamp.
745
+ const now = Date.now();
746
+ adapter.handleRawMessage(makeAssistantMsg());
747
+
748
+ expect(browserMessageCb).toHaveBeenCalledOnce();
749
+ const msg = browserMessageCb.mock.calls[0][0];
750
+ expect(msg.type).toBe("assistant");
751
+ expect(msg.message.id).toBe("msg-1");
752
+ expect(msg.message.role).toBe("assistant");
753
+ expect(msg.message.content[0].text).toBe("Hello world");
754
+ expect(msg.parent_tool_use_id).toBeNull();
755
+ // Timestamp should be roughly "now"
756
+ expect(msg.timestamp).toBeGreaterThanOrEqual(now);
757
+ expect(msg.timestamp).toBeLessThanOrEqual(Date.now());
758
+ });
759
+
760
+ it("result → emits browserMessageCb with result data", () => {
761
+ // A result message should be forwarded as-is in the data field.
762
+ adapter.handleRawMessage(makeResultMsg());
763
+
764
+ expect(browserMessageCb).toHaveBeenCalledOnce();
765
+ const msg = browserMessageCb.mock.calls[0][0];
766
+ expect(msg.type).toBe("result");
767
+ expect(msg.data.subtype).toBe("success");
768
+ expect(msg.data.total_cost_usd).toBe(0.01);
769
+ expect(msg.data.num_turns).toBe(1);
770
+ });
771
+
772
+ it("stream_event → emits browserMessageCb with stream_event", () => {
773
+ // Stream events should be forwarded with the event payload and
774
+ // parent_tool_use_id.
775
+ adapter.handleRawMessage(makeStreamEventMsg());
776
+
777
+ expect(browserMessageCb).toHaveBeenCalledOnce();
778
+ const msg = browserMessageCb.mock.calls[0][0];
779
+ expect(msg.type).toBe("stream_event");
780
+ expect(msg.event.type).toBe("content_block_delta");
781
+ expect(msg.parent_tool_use_id).toBeNull();
782
+ });
783
+
784
+ it("control_request (can_use_tool) → emits browserMessageCb with permission_request", () => {
785
+ // A tool permission request from the CLI should be translated into
786
+ // a permission_request browser message with all relevant fields.
787
+ adapter.handleRawMessage(makeControlRequestMsg());
788
+
789
+ expect(browserMessageCb).toHaveBeenCalledOnce();
790
+ const msg = browserMessageCb.mock.calls[0][0];
791
+ expect(msg.type).toBe("permission_request");
792
+ expect(msg.request.request_id).toBe("ctrl-req-1");
793
+ expect(msg.request.tool_name).toBe("Bash");
794
+ expect(msg.request.input).toEqual({ command: "ls" });
795
+ expect(msg.request.description).toBe("List files");
796
+ expect(msg.request.tool_use_id).toBe("tu-1");
797
+ // Timestamp should be set by the adapter
798
+ expect(typeof msg.request.timestamp).toBe("number");
799
+ });
800
+
801
+ it("tool_progress → emits browserMessageCb with tool_progress", () => {
802
+ // Tool progress updates should be forwarded to the browser.
803
+ adapter.handleRawMessage(makeToolProgressMsg());
804
+
805
+ expect(browserMessageCb).toHaveBeenCalledOnce();
806
+ const msg = browserMessageCb.mock.calls[0][0];
807
+ expect(msg.type).toBe("tool_progress");
808
+ expect(msg.tool_use_id).toBe("tu-1");
809
+ expect(msg.tool_name).toBe("Bash");
810
+ expect(msg.elapsed_time_seconds).toBe(2);
811
+ });
812
+
813
+ it("auth_status → emits browserMessageCb with auth_status", () => {
814
+ // Auth status updates should be forwarded with all relevant fields.
815
+ adapter.handleRawMessage(makeAuthStatusMsg());
816
+
817
+ expect(browserMessageCb).toHaveBeenCalledOnce();
818
+ const msg = browserMessageCb.mock.calls[0][0];
819
+ expect(msg.type).toBe("auth_status");
820
+ expect(msg.isAuthenticating).toBe(true);
821
+ expect(msg.output).toEqual(["Authenticating..."]);
822
+ });
823
+
824
+ it("auth_status with error → includes error in emission", () => {
825
+ // When the auth status includes an error, it should be forwarded.
826
+ adapter.handleRawMessage(makeAuthStatusMsg({ error: "Token expired" }));
827
+
828
+ const msg = browserMessageCb.mock.calls[0][0];
829
+ expect(msg.type).toBe("auth_status");
830
+ expect(msg.error).toBe("Token expired");
831
+ });
832
+
833
+ it("keep_alive → no emission to browser", () => {
834
+ // Keep-alive messages are silently consumed and should not be forwarded.
835
+ adapter.handleRawMessage(makeKeepAliveMsg());
836
+ expect(browserMessageCb).not.toHaveBeenCalled();
837
+ });
838
+
839
+ it("tool_use_summary → emits browserMessageCb with tool_use_summary", () => {
840
+ // Tool use summary messages should be forwarded with summary text and tool IDs.
841
+ const summaryMsg = JSON.stringify({
842
+ type: "tool_use_summary",
843
+ summary: "Ran bash command successfully",
844
+ preceding_tool_use_ids: ["tu-1", "tu-2"],
845
+ uuid: "tus-uuid-1",
846
+ session_id: "cli-123",
847
+ });
848
+ adapter.handleRawMessage(summaryMsg);
849
+
850
+ expect(browserMessageCb).toHaveBeenCalledOnce();
851
+ const msg = browserMessageCb.mock.calls[0][0];
852
+ expect(msg.type).toBe("tool_use_summary");
853
+ expect(msg.summary).toBe("Ran bash command successfully");
854
+ expect(msg.tool_use_ids).toEqual(["tu-1", "tu-2"]);
855
+ });
856
+
857
+ it("multiple NDJSON lines in one message are all processed", () => {
858
+ // The CLI may send multiple JSON objects separated by newlines in
859
+ // a single WebSocket message. All should be parsed and routed.
860
+ const combined = makeStreamEventMsg({ uuid: "s1" }) + "\n" + makeToolProgressMsg();
861
+ adapter.handleRawMessage(combined);
862
+ expect(browserMessageCb).toHaveBeenCalledTimes(2);
863
+ expect(browserMessageCb.mock.calls[0][0].type).toBe("stream_event");
864
+ expect(browserMessageCb.mock.calls[1][0].type).toBe("tool_progress");
865
+ });
866
+
867
+ it("malformed JSON lines are skipped without crashing", () => {
868
+ // If a line in the NDJSON cannot be parsed, it should be skipped
869
+ // and subsequent valid lines should still be processed.
870
+ // The parse error also surfaces as an error message to the browser.
871
+ const spy = vi.spyOn(log, "warn").mockImplementation(() => {});
872
+ const raw = "not json\n" + makeAssistantMsg();
873
+ adapter.handleRawMessage(raw);
874
+ // Parse error surfaced + valid assistant message processed
875
+ const calls = browserMessageCb.mock.calls.map((args: any[]) => args[0].type);
876
+ expect(calls).toContain("error");
877
+ expect(calls).toContain("assistant");
878
+ spy.mockRestore();
879
+ });
880
+ });
881
+
882
+ // ─── Activity update callback ───────────────────────────────────────────────
883
+
884
+ describe("Activity update callback", () => {
885
+ let ws: ReturnType<typeof createMockSocket>;
886
+
887
+ beforeEach(() => {
888
+ ws = createMockSocket("sess-1");
889
+ adapter.attachWebSocket(ws);
890
+ });
891
+
892
+ it("onActivityUpdate called on non-keepalive messages", () => {
893
+ // The activity update callback is used for idle detection. It should
894
+ // fire for all message types except keep_alive.
895
+ adapter.handleRawMessage(makeAssistantMsg());
896
+ expect(onActivityUpdate).toHaveBeenCalledOnce();
897
+ });
898
+
899
+ it("onActivityUpdate NOT called on keep_alive messages", () => {
900
+ // Keep-alive messages don't represent real activity and should not
901
+ // trigger the activity update callback.
902
+ adapter.handleRawMessage(makeKeepAliveMsg());
903
+ expect(onActivityUpdate).not.toHaveBeenCalled();
904
+ });
905
+
906
+ it("onActivityUpdate called for system, result, stream_event, control_request, tool_progress", () => {
907
+ // Verify the callback fires for multiple different message types.
908
+ adapter.handleRawMessage(makeInitMsg());
909
+ adapter.handleRawMessage(makeResultMsg());
910
+ adapter.handleRawMessage(makeStreamEventMsg());
911
+ adapter.handleRawMessage(makeControlRequestMsg());
912
+ adapter.handleRawMessage(makeToolProgressMsg());
913
+
914
+ // init + result + stream_event + control_request + tool_progress = 5 calls
915
+ expect(onActivityUpdate).toHaveBeenCalledTimes(5);
916
+ });
917
+ });
918
+
919
+ // ─── Deduplication ──────────────────────────────────────────────────────────
920
+
921
+ describe("Deduplication", () => {
922
+ let ws: ReturnType<typeof createMockSocket>;
923
+
924
+ beforeEach(() => {
925
+ ws = createMockSocket("sess-1");
926
+ adapter.attachWebSocket(ws);
927
+ });
928
+
929
+ it("duplicate assistant messages are filtered out", () => {
930
+ // When the CLI replays messages on WebSocket reconnect, the same
931
+ // assistant message sent twice should only be processed once.
932
+ const assistantNdjson = makeAssistantMsg();
933
+ adapter.handleRawMessage(assistantNdjson);
934
+ adapter.handleRawMessage(assistantNdjson);
935
+
936
+ // Only the first should have been emitted
937
+ expect(browserMessageCb).toHaveBeenCalledOnce();
938
+ expect(browserMessageCb.mock.calls[0][0].type).toBe("assistant");
939
+ });
940
+
941
+ it("duplicate stream_events with same uuid are filtered out", () => {
942
+ // Stream events with the same UUID should be deduplicated.
943
+ const streamNdjson = makeStreamEventMsg({ uuid: "dup-stream-uuid" });
944
+ adapter.handleRawMessage(streamNdjson);
945
+ adapter.handleRawMessage(streamNdjson);
946
+
947
+ // Only the first should have been emitted
948
+ expect(browserMessageCb).toHaveBeenCalledOnce();
949
+ });
950
+
951
+ it("stream_events with different uuids are NOT filtered", () => {
952
+ // Different UUIDs indicate distinct events that should both be processed.
953
+ adapter.handleRawMessage(makeStreamEventMsg({ uuid: "stream-1" }));
954
+ adapter.handleRawMessage(makeStreamEventMsg({ uuid: "stream-2" }));
955
+
956
+ expect(browserMessageCb).toHaveBeenCalledTimes(2);
957
+ });
958
+
959
+ it("non-deduplicable message types (tool_progress, control_request) are never filtered", () => {
960
+ // Tool progress and control request messages should never be deduplicated,
961
+ // even if sent identically twice.
962
+ const toolProgressNdjson = makeToolProgressMsg();
963
+ adapter.handleRawMessage(toolProgressNdjson);
964
+ adapter.handleRawMessage(toolProgressNdjson);
965
+ expect(browserMessageCb).toHaveBeenCalledTimes(2);
966
+ });
967
+ });
968
+
969
+ // ─── Control request/response flow ──────────────────────────────────────────
970
+
971
+ describe("Control request/response flow", () => {
972
+ let ws: ReturnType<typeof createMockSocket>;
973
+
974
+ beforeEach(() => {
975
+ ws = createMockSocket("sess-1");
976
+ adapter.attachWebSocket(ws);
977
+ });
978
+
979
+ it("MCP status request creates pending control request and resolves on response", () => {
980
+ // When mcp_get_status is sent, the adapter creates a pending control
981
+ // request. When the CLI responds with the matching request_id, the
982
+ // adapter should resolve it and emit mcp_status to browsers.
983
+ uuidCounter = 100; // Ensure deterministic request_id
984
+ adapter.send({ type: "mcp_get_status" });
985
+
986
+ // Extract the request_id from what was sent to the CLI
987
+ const sentRaw = (ws.send.mock.calls[0][0] as string).trim();
988
+ const sent = JSON.parse(sentRaw);
989
+ const requestId = sent.request_id;
990
+
991
+ // Simulate CLI response with matching request_id and MCP servers
992
+ const controlResponse = JSON.stringify({
993
+ type: "control_response",
994
+ response: {
995
+ subtype: "success",
996
+ request_id: requestId,
997
+ response: {
998
+ mcpServers: [
999
+ { name: "test-server", status: "connected", config: { type: "stdio" }, scope: "project", tools: [] },
1000
+ ],
1001
+ },
1002
+ },
1003
+ });
1004
+ adapter.handleRawMessage(controlResponse);
1005
+
1006
+ // The adapter should have emitted an mcp_status browser message
1007
+ expect(browserMessageCb).toHaveBeenCalledOnce();
1008
+ const msg = browserMessageCb.mock.calls[0][0];
1009
+ expect(msg.type).toBe("mcp_status");
1010
+ expect(msg.servers).toHaveLength(1);
1011
+ expect(msg.servers[0].name).toBe("test-server");
1012
+ });
1013
+
1014
+ it("control response with no pending request is silently ignored", () => {
1015
+ // If a control_response arrives with an unknown request_id,
1016
+ // it should be silently ignored (no crash, no emission).
1017
+ const controlResponse = JSON.stringify({
1018
+ type: "control_response",
1019
+ response: {
1020
+ subtype: "success",
1021
+ request_id: "unknown-request-id",
1022
+ response: {},
1023
+ },
1024
+ });
1025
+ adapter.handleRawMessage(controlResponse);
1026
+ // No emission to the browser
1027
+ expect(browserMessageCb).not.toHaveBeenCalled();
1028
+ });
1029
+
1030
+ it("error control response logs warning and doesn't call resolve", () => {
1031
+ // When the CLI responds with an error control_response, the adapter
1032
+ // should log a warning and NOT call the resolve callback. The pending
1033
+ // request should be cleaned up.
1034
+ uuidCounter = 200;
1035
+ adapter.send({ type: "mcp_get_status" });
1036
+
1037
+ const sentRaw = (ws.send.mock.calls[0][0] as string).trim();
1038
+ const sent = JSON.parse(sentRaw);
1039
+ const requestId = sent.request_id;
1040
+
1041
+ const errorResponse = JSON.stringify({
1042
+ type: "control_response",
1043
+ response: {
1044
+ subtype: "error",
1045
+ request_id: requestId,
1046
+ error: "MCP status unavailable",
1047
+ },
1048
+ });
1049
+ adapter.handleRawMessage(errorResponse);
1050
+
1051
+ // console.warn should have been called with the error
1052
+ expect(console.warn).toHaveBeenCalledWith(
1053
+ expect.stringContaining("mcp_status failed"),
1054
+ // Note: console.warn is a mock, we just check the first arg
1055
+ );
1056
+ // No mcp_status should have been emitted
1057
+ expect(browserMessageCb).not.toHaveBeenCalled();
1058
+ });
1059
+
1060
+ it("pending control request is removed after successful resolution", () => {
1061
+ // After a control response resolves a pending request, sending the
1062
+ // same response again should be a no-op (not double-resolve).
1063
+ uuidCounter = 300;
1064
+ adapter.send({ type: "mcp_get_status" });
1065
+
1066
+ const sentRaw = (ws.send.mock.calls[0][0] as string).trim();
1067
+ const sent = JSON.parse(sentRaw);
1068
+ const requestId = sent.request_id;
1069
+
1070
+ const successResponse = JSON.stringify({
1071
+ type: "control_response",
1072
+ response: {
1073
+ subtype: "success",
1074
+ request_id: requestId,
1075
+ response: { mcpServers: [] },
1076
+ },
1077
+ });
1078
+
1079
+ // First resolution
1080
+ adapter.handleRawMessage(successResponse);
1081
+ expect(browserMessageCb).toHaveBeenCalledOnce();
1082
+
1083
+ // Second resolution — should be ignored (pending already removed)
1084
+ browserMessageCb.mockClear();
1085
+ adapter.handleRawMessage(successResponse);
1086
+ expect(browserMessageCb).not.toHaveBeenCalled();
1087
+ });
1088
+ });
1089
+
1090
+ // ─── System init flushes pending messages ───────────────────────────────────
1091
+
1092
+ describe("System init flushes pending messages", () => {
1093
+ it("messages queued before init are flushed after init", () => {
1094
+ // When the CLI socket is connected but the adapter has pending messages
1095
+ // from before the connection, the init handler also flushes them.
1096
+ // This tests the scenario where messages are sent before the socket
1097
+ // is attached, then the socket attaches (flushing queue), and additional
1098
+ // messages are sent before init — those get queued internally too.
1099
+
1100
+ // First, send a message before socket is attached (queued)
1101
+ adapter.send({ type: "user_message", content: "queued-msg" });
1102
+
1103
+ // Attach socket — this flushes the pendingMessages
1104
+ const ws = createMockSocket("sess-1");
1105
+ adapter.attachWebSocket(ws);
1106
+
1107
+ expect(ws.send).toHaveBeenCalledTimes(1);
1108
+ const firstSent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
1109
+ expect(firstSent.message.content).toBe("queued-msg");
1110
+ });
1111
+ });
1112
+
1113
+ // ─── Edge cases ─────────────────────────────────────────────────────────────
1114
+
1115
+ describe("Edge cases", () => {
1116
+ it("adapter works without onActivityUpdate callback", () => {
1117
+ // Creating an adapter without the onActivityUpdate option should not
1118
+ // cause errors when processing messages.
1119
+ const plainAdapter = new ClaudeAdapter("sess-2");
1120
+ const cb = vi.fn();
1121
+ plainAdapter.onBrowserMessage(cb);
1122
+
1123
+ const ws = createMockSocket("sess-2");
1124
+ plainAdapter.attachWebSocket(ws);
1125
+ plainAdapter.handleRawMessage(makeAssistantMsg());
1126
+
1127
+ expect(cb).toHaveBeenCalledOnce();
1128
+ });
1129
+
1130
+ it("adapter works without any callbacks registered", () => {
1131
+ // Processing messages without any registered callbacks should not throw.
1132
+ const plainAdapter = new ClaudeAdapter("sess-3");
1133
+ const ws = createMockSocket("sess-3");
1134
+ plainAdapter.attachWebSocket(ws);
1135
+
1136
+ // Should not throw even without callbacks
1137
+ expect(() => plainAdapter.handleRawMessage(makeInitMsg())).not.toThrow();
1138
+ expect(() => plainAdapter.handleRawMessage(makeAssistantMsg())).not.toThrow();
1139
+ });
1140
+
1141
+ it("system init with agents, slash_commands, and skills", () => {
1142
+ // Verify that optional fields from the init message are forwarded.
1143
+ const cb = vi.fn();
1144
+ adapter.onBrowserMessage(cb);
1145
+
1146
+ const ws = createMockSocket("sess-1");
1147
+ adapter.attachWebSocket(ws);
1148
+
1149
+ adapter.handleRawMessage(
1150
+ makeInitMsg({
1151
+ agents: ["agent-1"],
1152
+ slash_commands: ["/help"],
1153
+ skills: ["skill-1"],
1154
+ }),
1155
+ );
1156
+
1157
+ // We have 2 registered callbacks for browserMessage (one from beforeEach, one here)
1158
+ // but only the last one registered on the adapter will fire (it overwrites).
1159
+ const msg = cb.mock.calls[0][0];
1160
+ expect(msg.session.agents).toEqual(["agent-1"]);
1161
+ expect(msg.session.slash_commands).toEqual(["/help"]);
1162
+ expect(msg.session.skills).toEqual(["skill-1"]);
1163
+ });
1164
+
1165
+ it("empty NDJSON data produces no emissions", () => {
1166
+ // An empty string or whitespace-only message should produce no emissions.
1167
+ const ws = createMockSocket("sess-1");
1168
+ adapter.attachWebSocket(ws);
1169
+ adapter.handleRawMessage("");
1170
+ adapter.handleRawMessage(" \n \n ");
1171
+ expect(browserMessageCb).not.toHaveBeenCalled();
1172
+ });
1173
+ });
1174
+
1175
+ // ─── control_cancel_request handling ─────────────────────────────────────────
1176
+
1177
+ describe("control_cancel_request", () => {
1178
+ it("emits permission_cancelled to browser", () => {
1179
+ // When the CLI cancels a pending control request (e.g. tool permission
1180
+ // that is no longer needed), the adapter should notify browsers so they
1181
+ // can remove the pending permission UI.
1182
+ const ws = createMockSocket("sess-1");
1183
+ adapter.attachWebSocket(ws);
1184
+
1185
+ const msg = JSON.stringify({ type: "control_cancel_request", request_id: "req-cancel-1" });
1186
+ adapter.handleRawMessage(msg);
1187
+
1188
+ expect(browserMessageCb).toHaveBeenCalledOnce();
1189
+ const emitted = browserMessageCb.mock.calls[0][0];
1190
+ expect(emitted.type).toBe("permission_cancelled");
1191
+ expect(emitted.request_id).toBe("req-cancel-1");
1192
+ });
1193
+ });
1194
+
1195
+ // ─── Enriched can_use_tool fields ────────────────────────────────────────────
1196
+
1197
+ describe("enriched can_use_tool", () => {
1198
+ it("forwards title, display_name, blocked_path, decision_reason", () => {
1199
+ // Newer CLI versions may include enriched fields on can_use_tool requests
1200
+ // (title, display_name, blocked_path, decision_reason). The adapter should
1201
+ // forward all of these to the browser in the permission_request.
1202
+ const ws = createMockSocket("sess-1");
1203
+ adapter.attachWebSocket(ws);
1204
+
1205
+ const msg = JSON.stringify({
1206
+ type: "control_request",
1207
+ request_id: "req-enriched-1",
1208
+ request: {
1209
+ subtype: "can_use_tool",
1210
+ tool_name: "Edit",
1211
+ input: { file_path: "/test.ts" },
1212
+ tool_use_id: "tu-enriched-1",
1213
+ title: "Edit a TypeScript file",
1214
+ display_name: "File Editor",
1215
+ blocked_path: "/test.ts",
1216
+ decision_reason: "File is outside trusted directories",
1217
+ },
1218
+ });
1219
+ adapter.handleRawMessage(msg);
1220
+
1221
+ expect(browserMessageCb).toHaveBeenCalledOnce();
1222
+ const emitted = browserMessageCb.mock.calls[0][0];
1223
+ expect(emitted.type).toBe("permission_request");
1224
+ const perm = emitted.request;
1225
+ expect(perm.title).toBe("Edit a TypeScript file");
1226
+ expect(perm.display_name).toBe("File Editor");
1227
+ expect(perm.blocked_path).toBe("/test.ts");
1228
+ expect(perm.decision_reason).toBe("File is outside trusted directories");
1229
+ });
1230
+
1231
+ it("works without enriched fields (backward compat)", () => {
1232
+ // Older CLI versions do not include enriched fields. The adapter should
1233
+ // still emit a valid permission_request with those fields undefined.
1234
+ const ws = createMockSocket("sess-1");
1235
+ adapter.attachWebSocket(ws);
1236
+
1237
+ const msg = JSON.stringify({
1238
+ type: "control_request",
1239
+ request_id: "req-basic-1",
1240
+ request: {
1241
+ subtype: "can_use_tool",
1242
+ tool_name: "Bash",
1243
+ input: { command: "ls" },
1244
+ tool_use_id: "tu-basic-1",
1245
+ },
1246
+ });
1247
+ adapter.handleRawMessage(msg);
1248
+
1249
+ expect(browserMessageCb).toHaveBeenCalledOnce();
1250
+ const emitted = browserMessageCb.mock.calls[0][0];
1251
+ expect(emitted.type).toBe("permission_request");
1252
+ const perm = emitted.request;
1253
+ expect(perm.title).toBeUndefined();
1254
+ expect(perm.display_name).toBeUndefined();
1255
+ });
1256
+ });
1257
+
1258
+ // ─── end_session outgoing ────────────────────────────────────────────────────
1259
+
1260
+ describe("end_session", () => {
1261
+ it("sends end_session control_request to CLI", () => {
1262
+ // The browser can request the session to end. This should be translated
1263
+ // into a control_request with subtype "end_session" and the reason forwarded.
1264
+ const ws = createMockSocket("sess-1");
1265
+ adapter.attachWebSocket(ws);
1266
+
1267
+ adapter.send({ type: "end_session", reason: "user closed" } as any);
1268
+
1269
+ const sent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
1270
+ expect(sent.type).toBe("control_request");
1271
+ expect(sent.request.subtype).toBe("end_session");
1272
+ expect(sent.request.reason).toBe("user closed");
1273
+ });
1274
+ });
1275
+
1276
+ // ─── stop_task outgoing ──────────────────────────────────────────────────────
1277
+
1278
+ describe("stop_task", () => {
1279
+ it("sends stop_task control_request to CLI", () => {
1280
+ // The browser can stop a running task. This should be translated into
1281
+ // a control_request with subtype "stop_task" and the task_id forwarded.
1282
+ const ws = createMockSocket("sess-1");
1283
+ adapter.attachWebSocket(ws);
1284
+
1285
+ adapter.send({ type: "stop_task", task_id: "task-123" } as any);
1286
+
1287
+ const sent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
1288
+ expect(sent.type).toBe("control_request");
1289
+ expect(sent.request.subtype).toBe("stop_task");
1290
+ expect(sent.request.task_id).toBe("task-123");
1291
+ });
1292
+ });
1293
+
1294
+ // ─── update_environment_variables outgoing ───────────────────────────────────
1295
+
1296
+ describe("update_environment_variables", () => {
1297
+ it("sends update_environment_variables directly (not as control_request)", () => {
1298
+ // Environment variable updates are sent as a top-level message type,
1299
+ // not wrapped in a control_request, because the CLI expects them
1300
+ // as a distinct message kind.
1301
+ const ws = createMockSocket("sess-1");
1302
+ adapter.attachWebSocket(ws);
1303
+
1304
+ adapter.send({ type: "update_environment_variables", variables: { TOKEN: "new-val" } } as any);
1305
+
1306
+ const sent = JSON.parse((ws.send.mock.calls[0][0] as string).trim());
1307
+ expect(sent.type).toBe("update_environment_variables");
1308
+ expect(sent.variables.TOKEN).toBe("new-val");
1309
+ });
1310
+ });
1311
+
1312
+ // ─── Streamlined messages ────────────────────────────────────────────────────
1313
+
1314
+ describe("streamlined messages", () => {
1315
+ it("forwards streamlined_text to browser", () => {
1316
+ // In simplified output mode, the CLI sends streamlined_text messages
1317
+ // instead of full assistant messages. These should be forwarded as-is.
1318
+ const ws = createMockSocket("sess-1");
1319
+ adapter.attachWebSocket(ws);
1320
+
1321
+ const msg = JSON.stringify({ type: "streamlined_text", text: "Hello world", session_id: "s1", uuid: "u1" });
1322
+ adapter.handleRawMessage(msg);
1323
+
1324
+ expect(browserMessageCb).toHaveBeenCalledOnce();
1325
+ const emitted = browserMessageCb.mock.calls[0][0];
1326
+ expect(emitted.type).toBe("streamlined_text");
1327
+ expect(emitted.text).toBe("Hello world");
1328
+ });
1329
+
1330
+ it("forwards streamlined_tool_use_summary to browser", () => {
1331
+ // In simplified output mode, tool use summaries are sent as
1332
+ // streamlined_tool_use_summary. These should be forwarded with the summary text.
1333
+ const ws = createMockSocket("sess-1");
1334
+ adapter.attachWebSocket(ws);
1335
+
1336
+ const msg = JSON.stringify({ type: "streamlined_tool_use_summary", tool_summary: "Read 2 files", session_id: "s1", uuid: "u2" });
1337
+ adapter.handleRawMessage(msg);
1338
+
1339
+ expect(browserMessageCb).toHaveBeenCalledOnce();
1340
+ const emitted = browserMessageCb.mock.calls[0][0];
1341
+ expect(emitted.type).toBe("streamlined_tool_use_summary");
1342
+ expect(emitted.tool_summary).toBe("Read 2 files");
1343
+ });
1344
+ });
1345
+
1346
+ // ─── Prompt suggestion ───────────────────────────────────────────────────────
1347
+
1348
+ describe("prompt_suggestion", () => {
1349
+ it("forwards prompt suggestions to browser", () => {
1350
+ // The CLI can suggest next prompts to the user. These should be forwarded
1351
+ // so the browser can render suggestion chips in the UI.
1352
+ const ws = createMockSocket("sess-1");
1353
+ adapter.attachWebSocket(ws);
1354
+
1355
+ const msg = JSON.stringify({ type: "prompt_suggestion", suggestions: ["Fix the bug", "Add tests"], session_id: "s1", uuid: "u3" });
1356
+ adapter.handleRawMessage(msg);
1357
+
1358
+ expect(browserMessageCb).toHaveBeenCalledOnce();
1359
+ const emitted = browserMessageCb.mock.calls[0][0];
1360
+ expect(emitted.type).toBe("prompt_suggestion");
1361
+ expect(emitted.suggestions).toEqual(["Fix the bug", "Add tests"]);
1362
+ });
1363
+ });