@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,4777 @@
1
+ import { vi } from "vitest";
2
+
3
+ // Stub Bun global for vitest (runs under Node, not Bun).
4
+ // Bun.hash is used for CLI message deduplication in ws-bridge.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
+ const mockExecSync = vi.hoisted(() => vi.fn());
20
+ vi.mock("node:child_process", () => ({ execSync: mockExecSync }));
21
+ vi.mock("node:crypto", () => ({ randomUUID: () => "test-uuid" }));
22
+
23
+ // Mock settings-manager to prevent AI validation from interfering with tests.
24
+ // Without this mock, the real settings file (~/.companion/settings.json) may have
25
+ // aiValidationEnabled: true, causing handleControlRequest to call validatePermission
26
+ // (an external API call) and auto-approve/deny permissions before they reach pendingPermissions.
27
+ vi.mock("./settings-manager.js", () => ({
28
+ getSettings: () => ({
29
+ aiValidationEnabled: false,
30
+ aiValidationAutoApprove: false,
31
+ aiValidationAutoDeny: false,
32
+ anthropicApiKey: "",
33
+ }),
34
+ DEFAULT_ANTHROPIC_MODEL: "claude-sonnet-4-6",
35
+ }));
36
+
37
+ import { WsBridge, type SocketData } from "./ws-bridge.js";
38
+ import { SessionStore } from "./session-store.js";
39
+ import { containerManager } from "./container-manager.js";
40
+ import { companionBus } from "./event-bus.js";
41
+ import { mkdtempSync, rmSync } from "node:fs";
42
+ import { join } from "node:path";
43
+ import { tmpdir } from "node:os";
44
+
45
+ function createMockSocket(data: SocketData) {
46
+ return {
47
+ data,
48
+ send: vi.fn(),
49
+ close: vi.fn(),
50
+ readyState: 1,
51
+ } as any;
52
+ }
53
+
54
+ function makeCliSocket(sessionId: string) {
55
+ return createMockSocket({ kind: "cli", sessionId });
56
+ }
57
+
58
+ function makeBrowserSocket(sessionId: string) {
59
+ return createMockSocket({ kind: "browser", sessionId });
60
+ }
61
+
62
+ let bridge: WsBridge;
63
+ let tempDir: string;
64
+ let store: SessionStore;
65
+
66
+ beforeEach(() => {
67
+ tempDir = mkdtempSync(join(tmpdir(), "bridge-test-"));
68
+ store = new SessionStore(tempDir);
69
+ bridge = new WsBridge();
70
+ bridge.setStore(store);
71
+ mockExecSync.mockReset();
72
+ companionBus.clear();
73
+ // Suppress console output to prevent Vitest EnvironmentTeardownError.
74
+ // ws-bridge.ts and session-store.ts log via console.log/warn/error;
75
+ // when the Vitest worker tears down while a console relay RPC is still
76
+ // in-flight, it causes "Closing rpc while onUserConsoleLog was pending".
77
+ vi.spyOn(console, "log").mockImplementation(() => {});
78
+ vi.spyOn(console, "warn").mockImplementation(() => {});
79
+ vi.spyOn(console, "error").mockImplementation(() => {});
80
+ });
81
+
82
+ afterEach(() => {
83
+ // Cancel pending debounce timers from SessionStore before removing
84
+ // the temp directory. Without this, debounced writes fire after rmSync
85
+ // and produce console.error calls that race with Vitest worker teardown.
86
+ store.dispose();
87
+ rmSync(tempDir, { recursive: true, force: true });
88
+ vi.restoreAllMocks();
89
+ });
90
+
91
+ // Re-suppress console after the last test to prevent "Closing rpc while
92
+ // onUserConsoleLog was pending" during Vitest worker teardown.
93
+ afterAll(() => {
94
+ const noop = () => {};
95
+ console.log = noop;
96
+ console.warn = noop;
97
+ console.error = noop;
98
+ });
99
+
100
+ // ─── Helper: build a system.init NDJSON string ────────────────────────────────
101
+
102
+ function makeInitMsg(overrides: Record<string, unknown> = {}) {
103
+ return JSON.stringify({
104
+ type: "system",
105
+ subtype: "init",
106
+ session_id: "cli-123",
107
+ model: "claude-sonnet-4-6",
108
+ cwd: "/test",
109
+ tools: ["Bash", "Read"],
110
+ permissionMode: "default",
111
+ claude_code_version: "1.0",
112
+ mcp_servers: [],
113
+ agents: [],
114
+ slash_commands: [],
115
+ skills: [],
116
+ output_style: "normal",
117
+ uuid: "uuid-1",
118
+ apiKeySource: "env",
119
+ ...overrides,
120
+ });
121
+ }
122
+
123
+ // ─── Session management ──────────────────────────────────────────────────────
124
+
125
+ describe("Session management", () => {
126
+ it("getOrCreateSession: creates new session with default state", () => {
127
+ const session = bridge.getOrCreateSession("s1");
128
+ expect(session.id).toBe("s1");
129
+ expect(session.state.session_id).toBe("s1");
130
+ expect(session.state.model).toBe("");
131
+ expect(session.state.cwd).toBe("");
132
+ expect(session.state.tools).toEqual([]);
133
+ expect(session.state.permissionMode).toBe("default");
134
+ expect(session.state.total_cost_usd).toBe(0);
135
+ expect(session.state.num_turns).toBe(0);
136
+ expect(session.state.context_used_percent).toBe(0);
137
+ expect(session.state.is_compacting).toBe(false);
138
+ expect(session.state.git_branch).toBe("");
139
+ expect(session.state.is_worktree).toBe(false);
140
+ expect(session.state.is_containerized).toBe(false);
141
+ expect(session.state.repo_root).toBe("");
142
+ expect(session.state.git_ahead).toBe(0);
143
+ expect(session.state.git_behind).toBe(0);
144
+ expect(session.backendAdapter).toBeNull();
145
+ expect(session.browserSockets.size).toBe(0);
146
+ expect(session.pendingPermissions.size).toBe(0);
147
+ expect(session.messageHistory).toEqual([]);
148
+ expect(session.pendingMessages).toEqual([]);
149
+ });
150
+
151
+ it("getOrCreateSession: returns existing session on second call", () => {
152
+ const first = bridge.getOrCreateSession("s1");
153
+ first.state.model = "modified";
154
+ const second = bridge.getOrCreateSession("s1");
155
+ expect(second).toBe(first);
156
+ expect(second.state.model).toBe("modified");
157
+ });
158
+
159
+ it("getOrCreateSession: sets backendType when creating a new session", () => {
160
+ const session = bridge.getOrCreateSession("s1", "codex");
161
+ expect(session.backendType).toBe("codex");
162
+ expect(session.state.backend_type).toBe("codex");
163
+ });
164
+
165
+ it("getOrCreateSession: does NOT overwrite backendType when called without explicit type", () => {
166
+ // Simulate: attachCodexAdapter creates session as "codex"
167
+ const session = bridge.getOrCreateSession("s1", "codex");
168
+ expect(session.backendType).toBe("codex");
169
+ expect(session.state.backend_type).toBe("codex");
170
+
171
+ // Simulate: handleBrowserOpen calls getOrCreateSession without backendType
172
+ const same = bridge.getOrCreateSession("s1");
173
+ expect(same.backendType).toBe("codex");
174
+ expect(same.state.backend_type).toBe("codex");
175
+ });
176
+
177
+ it("getOrCreateSession: overwrites backendType when explicitly provided on existing session", () => {
178
+ const session = bridge.getOrCreateSession("s1");
179
+ expect(session.backendType).toBe("claude");
180
+
181
+ // Explicit override (e.g. attachCodexAdapter)
182
+ bridge.getOrCreateSession("s1", "codex");
183
+ expect(session.backendType).toBe("codex");
184
+ expect(session.state.backend_type).toBe("codex");
185
+ });
186
+
187
+ it("getSession: returns undefined for unknown session", () => {
188
+ expect(bridge.getSession("nonexistent")).toBeUndefined();
189
+ });
190
+
191
+ it("getAllSessions: returns all session states", () => {
192
+ bridge.getOrCreateSession("s1");
193
+ bridge.getOrCreateSession("s2");
194
+ bridge.getOrCreateSession("s3");
195
+ const all = bridge.getAllSessions();
196
+ expect(all).toHaveLength(3);
197
+ const ids = all.map((s) => s.session_id);
198
+ expect(ids).toContain("s1");
199
+ expect(ids).toContain("s2");
200
+ expect(ids).toContain("s3");
201
+ });
202
+
203
+ it("isCliConnected: returns false without CLI socket", () => {
204
+ bridge.getOrCreateSession("s1");
205
+ expect(bridge.isCliConnected("s1")).toBe(false);
206
+ expect(bridge.isCliConnected("nonexistent")).toBe(false);
207
+ });
208
+
209
+ it("removeSession: deletes from map and store", () => {
210
+ bridge.getOrCreateSession("s1");
211
+ const removeSpy = vi.spyOn(store, "remove");
212
+ bridge.removeSession("s1");
213
+ expect(bridge.getSession("s1")).toBeUndefined();
214
+ expect(removeSpy).toHaveBeenCalledWith("s1");
215
+ });
216
+
217
+ it("closeSession: closes all sockets and removes session", () => {
218
+ const cli = makeCliSocket("s1");
219
+ const browser1 = makeBrowserSocket("s1");
220
+ const browser2 = makeBrowserSocket("s1");
221
+
222
+ bridge.handleCLIOpen(cli, "s1");
223
+ bridge.handleBrowserOpen(browser1, "s1");
224
+ bridge.handleBrowserOpen(browser2, "s1");
225
+
226
+ bridge.closeSession("s1");
227
+
228
+ expect(cli.close).toHaveBeenCalled();
229
+ expect(browser1.close).toHaveBeenCalled();
230
+ expect(browser2.close).toHaveBeenCalled();
231
+ expect(bridge.getSession("s1")).toBeUndefined();
232
+ });
233
+ });
234
+
235
+ // ─── prePopulateCommands ─────────────────────────────────────────────────────
236
+
237
+ describe("prePopulateCommands", () => {
238
+ it("populates empty session state with commands and skills", () => {
239
+ // When a session has no commands/skills yet, prePopulateCommands should
240
+ // set them so the slash menu works before system.init arrives.
241
+ bridge.prePopulateCommands("s1", ["commit", "review-pr"], ["my-skill"]);
242
+ const session = bridge.getSession("s1")!;
243
+ expect(session.state.slash_commands).toEqual(["commit", "review-pr"]);
244
+ expect(session.state.skills).toEqual(["my-skill"]);
245
+ });
246
+
247
+ it("does not overwrite existing commands if already set", () => {
248
+ // If system.init already arrived and set commands, prePopulateCommands
249
+ // should not clobber them (guard against race condition).
250
+ const session = bridge.getOrCreateSession("s1");
251
+ session.state.slash_commands = ["existing-cmd"];
252
+ session.state.skills = ["existing-skill"];
253
+
254
+ bridge.prePopulateCommands("s1", ["new-cmd"], ["new-skill"]);
255
+
256
+ expect(session.state.slash_commands).toEqual(["existing-cmd"]);
257
+ expect(session.state.skills).toEqual(["existing-skill"]);
258
+ });
259
+
260
+ it("partially populates when only one field is empty", () => {
261
+ // If commands are already set but skills are empty, only skills
262
+ // should be populated.
263
+ const session = bridge.getOrCreateSession("s1");
264
+ session.state.slash_commands = ["existing-cmd"];
265
+ session.state.skills = [];
266
+
267
+ bridge.prePopulateCommands("s1", ["new-cmd"], ["new-skill"]);
268
+
269
+ expect(session.state.slash_commands).toEqual(["existing-cmd"]);
270
+ expect(session.state.skills).toEqual(["new-skill"]);
271
+ });
272
+
273
+ it("does nothing when provided arrays are empty", () => {
274
+ // Empty discovery results should not replace the (also empty) defaults.
275
+ bridge.prePopulateCommands("s1", [], []);
276
+ const session = bridge.getSession("s1")!;
277
+ expect(session.state.slash_commands).toEqual([]);
278
+ expect(session.state.skills).toEqual([]);
279
+ });
280
+
281
+ it("pre-populated data appears in session_init broadcast to browsers", () => {
282
+ // When a browser connects after prePopulateCommands, the session_init
283
+ // message should include the pre-populated commands/skills.
284
+ bridge.prePopulateCommands("s1", ["deploy"], ["prd"]);
285
+
286
+ const browser = makeBrowserSocket("s1");
287
+ bridge.handleBrowserOpen(browser, "s1");
288
+
289
+ // The session_init message sent to the browser should contain the pre-populated data
290
+ expect(browser.send).toHaveBeenCalled();
291
+ const sentData = JSON.parse(browser.send.mock.calls[0][0]);
292
+ expect(sentData.type).toBe("session_init");
293
+ expect(sentData.session.slash_commands).toEqual(["deploy"]);
294
+ expect(sentData.session.skills).toEqual(["prd"]);
295
+ });
296
+
297
+ it("broadcasts session_init to already-connected browsers when state changes", () => {
298
+ // If a browser is already connected when prePopulateCommands runs
299
+ // (e.g. discovery resolved after browser connected), the browser should
300
+ // receive a session_init with the updated commands/skills.
301
+ const browser = makeBrowserSocket("s1");
302
+ bridge.handleBrowserOpen(browser, "s1");
303
+ browser.send.mockClear();
304
+
305
+ bridge.prePopulateCommands("s1", ["deploy"], ["prd"]);
306
+
307
+ expect(browser.send).toHaveBeenCalledTimes(1);
308
+ const sentData = JSON.parse(browser.send.mock.calls[0][0]);
309
+ expect(sentData.type).toBe("session_init");
310
+ expect(sentData.session.slash_commands).toEqual(["deploy"]);
311
+ expect(sentData.session.skills).toEqual(["prd"]);
312
+ });
313
+
314
+ it("does not broadcast when no browsers are connected", () => {
315
+ // When no browsers are subscribed, prePopulateCommands should not
316
+ // attempt to broadcast (no-op beyond state mutation).
317
+ bridge.prePopulateCommands("s1", ["deploy"], ["prd"]);
318
+ const session = bridge.getSession("s1")!;
319
+ // State should still be updated
320
+ expect(session.state.slash_commands).toEqual(["deploy"]);
321
+ expect(session.state.skills).toEqual(["prd"]);
322
+ // No browser sockets to verify send wasn't called -- just ensure no throw
323
+ });
324
+
325
+ it("does not broadcast when state did not change", () => {
326
+ // When provided arrays are empty, no state change occurs and no
327
+ // broadcast should be sent even if browsers are connected.
328
+ const browser = makeBrowserSocket("s1");
329
+ bridge.handleBrowserOpen(browser, "s1");
330
+ browser.send.mockClear();
331
+
332
+ bridge.prePopulateCommands("s1", [], []);
333
+
334
+ expect(browser.send).not.toHaveBeenCalled();
335
+ });
336
+
337
+ it("system.init overwrites pre-populated data with authoritative list", async () => {
338
+ // After prePopulateCommands, when CLI sends system.init, the CLI's
339
+ // authoritative list should replace the pre-populated data.
340
+ bridge.prePopulateCommands("s1", ["pre-cmd"], ["pre-skill"]);
341
+
342
+ const cli = makeCliSocket("s1");
343
+ bridge.handleCLIOpen(cli, "s1");
344
+ await bridge.handleCLIMessage(
345
+ cli,
346
+ makeInitMsg({
347
+ slash_commands: ["cli-cmd-1", "cli-cmd-2"],
348
+ skills: ["cli-skill"],
349
+ }),
350
+ );
351
+
352
+ const session = bridge.getSession("s1")!;
353
+ expect(session.state.slash_commands).toEqual(["cli-cmd-1", "cli-cmd-2"]);
354
+ expect(session.state.skills).toEqual(["cli-skill"]);
355
+ });
356
+ });
357
+
358
+ // ─── CLI handlers ────────────────────────────────────────────────────────────
359
+
360
+ describe("CLI handlers", () => {
361
+ it("handleCLIOpen: sets backendAdapter and broadcasts cli_connected", () => {
362
+ const browser = makeBrowserSocket("s1");
363
+ bridge.handleBrowserOpen(browser, "s1");
364
+ // Clear session_init send calls
365
+ browser.send.mockClear();
366
+
367
+ const cli = makeCliSocket("s1");
368
+ bridge.handleCLIOpen(cli, "s1");
369
+
370
+ const session = bridge.getSession("s1")!;
371
+ expect(session.backendAdapter).not.toBeNull();
372
+ expect(session.backendAdapter?.isConnected()).toBe(true);
373
+ expect(bridge.isCliConnected("s1")).toBe(true);
374
+
375
+ // Should have broadcast cli_connected
376
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
377
+ expect(calls).toContainEqual(expect.objectContaining({ type: "cli_connected" }));
378
+ });
379
+
380
+ it("handleCLIOpen: flushes pending messages immediately", () => {
381
+ // Per the SDK protocol, the first user message triggers system.init,
382
+ // so queued messages must be flushed as soon as the CLI WebSocket connects
383
+ // (not deferred until system.init, which would create a deadlock for
384
+ // slow-starting sessions like Docker containers).
385
+ const browser = makeBrowserSocket("s1");
386
+ bridge.handleBrowserOpen(browser, "s1");
387
+
388
+ bridge.handleBrowserMessage(browser, JSON.stringify({
389
+ type: "user_message",
390
+ content: "hello queued",
391
+ }));
392
+
393
+ // CLI not yet connected, message should be queued
394
+ const session = bridge.getSession("s1")!;
395
+ expect(session.pendingMessages.length).toBe(1);
396
+
397
+ // Now connect CLI — messages should be flushed immediately
398
+ const cli = makeCliSocket("s1");
399
+ bridge.handleCLIOpen(cli, "s1");
400
+
401
+ // Pending should have been flushed
402
+ expect(session.pendingMessages).toEqual([]);
403
+ // The CLI socket should have received the queued message
404
+ expect(cli.send).toHaveBeenCalled();
405
+ const sentCalls = cli.send.mock.calls.map(([arg]: [string]) => arg);
406
+ const userMsg = sentCalls.find((s: string) => s.includes('"type":"user"'));
407
+ expect(userMsg).toBeDefined();
408
+ const parsed = JSON.parse(userMsg!.trim());
409
+ expect(parsed.type).toBe("user");
410
+ expect(parsed.message.content).toBe("hello queued");
411
+ });
412
+
413
+ it("handleCLIMessage: system.init does not re-flush already-sent messages", async () => {
414
+ // Messages are flushed on CLI connect, so by the time system.init
415
+ // arrives the queue should already be empty.
416
+ mockExecSync.mockImplementation(() => { throw new Error("not a git repo"); });
417
+
418
+ const browser = makeBrowserSocket("s1");
419
+ bridge.handleBrowserOpen(browser, "s1");
420
+
421
+ bridge.handleBrowserMessage(browser, JSON.stringify({
422
+ type: "user_message",
423
+ content: "hello queued",
424
+ }));
425
+
426
+ const session = bridge.getSession("s1")!;
427
+ expect(session.pendingMessages.length).toBe(1);
428
+
429
+ // Connect CLI — messages flushed immediately
430
+ const cli = makeCliSocket("s1");
431
+ bridge.handleCLIOpen(cli, "s1");
432
+ expect(session.pendingMessages).toEqual([]);
433
+ const sendCountAfterOpen = cli.send.mock.calls.length;
434
+
435
+ // Send system.init — no additional flush should happen
436
+ await bridge.handleCLIMessage(cli, makeInitMsg());
437
+
438
+ // Verify no additional user messages were sent after system.init
439
+ const newCalls = cli.send.mock.calls.slice(sendCountAfterOpen);
440
+ const userMsgAfterInit = newCalls.find(([arg]: [string]) => arg.includes('"type":"user"'));
441
+ expect(userMsgAfterInit).toBeUndefined();
442
+ });
443
+
444
+ it("handleCLIMessage: parses NDJSON and routes system.init", async () => {
445
+ mockExecSync.mockImplementation(() => {
446
+ throw new Error("not a git repo");
447
+ });
448
+
449
+ const cli = makeCliSocket("s1");
450
+ const browser = makeBrowserSocket("s1");
451
+ bridge.handleCLIOpen(cli, "s1");
452
+ bridge.handleBrowserOpen(browser, "s1");
453
+ browser.send.mockClear();
454
+
455
+ await bridge.handleCLIMessage(cli, makeInitMsg());
456
+
457
+ const session = bridge.getSession("s1")!;
458
+ expect(session.state.model).toBe("claude-sonnet-4-6");
459
+ expect(session.state.cwd).toBe("/test");
460
+
461
+ // Should broadcast session_init to browser
462
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
463
+ const initCall = calls.find((c: any) => c.type === "session_init");
464
+ expect(initCall).toBeDefined();
465
+ expect(initCall.session.model).toBe("claude-sonnet-4-6");
466
+ });
467
+
468
+ it("handleCLIMessage: system.init fires onCLISessionIdReceived callback", async () => {
469
+ mockExecSync.mockImplementation(() => {
470
+ throw new Error("not a git repo");
471
+ });
472
+
473
+ const callback = vi.fn();
474
+ companionBus.on("session:cli-id-received", ({ sessionId, cliSessionId }) => callback(sessionId, cliSessionId));
475
+
476
+ const cli = makeCliSocket("s1");
477
+ bridge.handleCLIOpen(cli, "s1");
478
+ await bridge.handleCLIMessage(cli, makeInitMsg({ session_id: "cli-internal-id" }));
479
+
480
+ expect(callback).toHaveBeenCalledWith("s1", "cli-internal-id");
481
+ });
482
+
483
+ it("handleCLIMessage: system.init preserves Companion session_id (does not overwrite with CLI internal ID)", async () => {
484
+ // Regression test for duplicate sidebar entries bug.
485
+ // The CLI sends its own internal session_id in the system.init message.
486
+ // The bridge must NOT allow this to overwrite session.state.session_id
487
+ // (which is the Companion's session ID used by the browser as a Map key).
488
+ // If overwritten, the browser adds the session under the CLI's ID while
489
+ // the sdkSessions poll uses the Companion's ID — creating two entries.
490
+ mockExecSync.mockImplementation(() => {
491
+ throw new Error("not a git repo");
492
+ });
493
+
494
+ const cli = makeCliSocket("s1");
495
+ const browser = makeBrowserSocket("s1");
496
+ bridge.handleCLIOpen(cli, "s1");
497
+ bridge.handleBrowserOpen(browser, "s1");
498
+ browser.send.mockClear();
499
+
500
+ // CLI reports a different session_id than the Companion's "s1"
501
+ await bridge.handleCLIMessage(cli, makeInitMsg({ session_id: "cli-internal-uuid-abc123" }));
502
+
503
+ const session = bridge.getSession("s1")!;
504
+ // session.state.session_id must remain the Companion's ID
505
+ expect(session.state.session_id).toBe("s1");
506
+
507
+ // The broadcast to the browser must also use the Companion's ID
508
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
509
+ const initCall = calls.find((c: any) => c.type === "session_init");
510
+ expect(initCall).toBeDefined();
511
+ expect(initCall.session.session_id).toBe("s1");
512
+ });
513
+
514
+ it("handleCLIMessage: session_update preserves Companion session_id (does not overwrite with CLI internal ID)", async () => {
515
+ // Regression test: after session_init lands, a subsequent session_update
516
+ // from the adapter must NOT overwrite session.state.session_id with the
517
+ // CLI's internal ID. This mirrors the session_init regression test above.
518
+ mockExecSync.mockImplementation(() => {
519
+ throw new Error("not a git repo");
520
+ });
521
+
522
+ const cli = makeCliSocket("s1");
523
+ bridge.handleCLIOpen(cli, "s1");
524
+
525
+ // First, send session_init to get the session into ready state
526
+ await bridge.handleCLIMessage(cli, makeInitMsg({ session_id: "cli-internal-uuid-abc123" }));
527
+
528
+ const session = bridge.getSession("s1")!;
529
+ expect(session.state.session_id).toBe("s1"); // sanity check after init
530
+
531
+ // Now simulate a session_update with a different session_id coming through
532
+ // the adapter pipeline. We invoke the adapter's browserMessageCb directly
533
+ // because the Claude adapter does not natively emit session_update — this
534
+ // path is exercised by the Codex adapter in production.
535
+ const adapter = session.backendAdapter as any;
536
+ adapter.browserMessageCb({
537
+ type: "session_update",
538
+ session: {
539
+ session_id: "cli-internal-uuid-abc123",
540
+ model: "claude-opus-4-6",
541
+ },
542
+ });
543
+
544
+ // session.state.session_id must still be the Companion's ID
545
+ expect(session.state.session_id).toBe("s1");
546
+ // The model update should still have been applied
547
+ expect(session.state.model).toBe("claude-opus-4-6");
548
+ });
549
+
550
+ it("handleCLIMessage: updates state from init (model, cwd, tools, permissionMode)", async () => {
551
+ mockExecSync.mockImplementation(() => {
552
+ throw new Error("not a git repo");
553
+ });
554
+
555
+ const cli = makeCliSocket("s1");
556
+ bridge.handleCLIOpen(cli, "s1");
557
+
558
+ await bridge.handleCLIMessage(cli, makeInitMsg({
559
+ model: "claude-opus-4-5-20250929",
560
+ cwd: "/workspace",
561
+ tools: ["Bash", "Read", "Edit"],
562
+ permissionMode: "bypassPermissions",
563
+ claude_code_version: "2.0",
564
+ mcp_servers: [{ name: "test-mcp", status: "connected" }],
565
+ agents: ["agent1"],
566
+ slash_commands: ["/commit"],
567
+ skills: ["pdf"],
568
+ }));
569
+
570
+ const state = bridge.getSession("s1")!.state;
571
+ expect(state.model).toBe("claude-opus-4-5-20250929");
572
+ expect(state.cwd).toBe("/workspace");
573
+ expect(state.tools).toEqual(["Bash", "Read", "Edit"]);
574
+ expect(state.permissionMode).toBe("bypassPermissions");
575
+ expect(state.claude_code_version).toBe("2.0");
576
+ expect(state.mcp_servers).toEqual([{ name: "test-mcp", status: "connected" }]);
577
+ expect(state.agents).toEqual(["agent1"]);
578
+ expect(state.slash_commands).toEqual(["/commit"]);
579
+ expect(state.skills).toEqual(["pdf"]);
580
+ });
581
+
582
+ it("handleCLIMessage: system.init preserves host cwd for containerized sessions", async () => {
583
+ // markContainerized sets the host cwd and is_containerized before CLI connects
584
+ bridge.markContainerized("s1", "/Users/stan/Dev/myproject");
585
+
586
+ mockExecSync.mockImplementation(() => {
587
+ throw new Error("container not tracked");
588
+ });
589
+
590
+ const cli = makeCliSocket("s1");
591
+ bridge.handleCLIOpen(cli, "s1");
592
+
593
+ // CLI inside the container reports /workspace — should be ignored
594
+ await bridge.handleCLIMessage(cli, makeInitMsg({ cwd: "/workspace" }));
595
+
596
+ const state = bridge.getSession("s1")!.state;
597
+ expect(state.cwd).toBe("/Users/stan/Dev/myproject");
598
+ expect(state.is_containerized).toBe(true);
599
+ });
600
+
601
+ it("handleCLIMessage: keeps previous git info when container metadata is temporarily unavailable", async () => {
602
+ const session = bridge.getOrCreateSession("s1");
603
+ session.state.git_branch = "existing-branch";
604
+ session.state.repo_root = "/workspace";
605
+ session.state.git_ahead = 2;
606
+ session.state.git_behind = 1;
607
+ bridge.markContainerized("s1", "/Users/stan/Dev/myproject");
608
+
609
+ mockExecSync.mockImplementation(() => {
610
+ throw new Error("container not tracked");
611
+ });
612
+
613
+ const cli = makeCliSocket("s1");
614
+ bridge.handleCLIOpen(cli, "s1");
615
+ await bridge.handleCLIMessage(cli, makeInitMsg({ cwd: "/workspace" }));
616
+
617
+ const state = bridge.getSession("s1")!.state;
618
+ expect(state.git_branch).toBe("existing-branch");
619
+ expect(state.repo_root).toBe("/workspace");
620
+ expect(state.git_ahead).toBe(2);
621
+ expect(state.git_behind).toBe(1);
622
+ });
623
+
624
+ it("handleCLIMessage: resolves git info from container for containerized sessions", async () => {
625
+ bridge.markContainerized("s1", "/Users/stan/Dev/myproject");
626
+ const getContainerSpy = vi.spyOn(containerManager, "getContainer").mockReturnValue({
627
+ containerId: "abc123def456",
628
+ name: "companion-test",
629
+ image: "the-companion:latest",
630
+ portMappings: [],
631
+ hostCwd: "/Users/stan/Dev/myproject",
632
+ containerCwd: "/workspace",
633
+ state: "running",
634
+ });
635
+
636
+ mockExecSync.mockImplementation((cmd: string) => {
637
+ if (!cmd.startsWith("docker exec abc123def456 sh -lc ")) {
638
+ throw new Error(`unexpected command: ${cmd}`);
639
+ }
640
+ if (cmd.includes("--abbrev-ref HEAD")) return "container-branch\n";
641
+ if (cmd.includes("--git-dir")) return ".git\n";
642
+ if (cmd.includes("--show-toplevel")) return "/workspace\n";
643
+ if (cmd.includes("--left-right --count")) return "1\t3\n";
644
+ throw new Error(`unknown git cmd: ${cmd}`);
645
+ });
646
+
647
+ const cli = makeCliSocket("s1");
648
+ bridge.handleCLIOpen(cli, "s1");
649
+ await bridge.handleCLIMessage(cli, makeInitMsg({ cwd: "/workspace" }));
650
+
651
+ const state = bridge.getSession("s1")!.state;
652
+ expect(state.cwd).toBe("/Users/stan/Dev/myproject");
653
+ expect(state.git_branch).toBe("container-branch");
654
+ expect(state.repo_root).toBe("/Users/stan/Dev/myproject");
655
+ expect(state.git_behind).toBe(1);
656
+ expect(state.git_ahead).toBe(3);
657
+ expect(getContainerSpy).toHaveBeenCalledWith("s1");
658
+ getContainerSpy.mockRestore();
659
+ });
660
+
661
+ it("handleCLIMessage: maps nested container repo_root paths back to host paths", async () => {
662
+ bridge.markContainerized("s1", "/Users/stan/Dev/myproject");
663
+ const getContainerSpy = vi.spyOn(containerManager, "getContainer").mockReturnValue({
664
+ containerId: "abc123def456",
665
+ name: "companion-test",
666
+ image: "the-companion:latest",
667
+ portMappings: [],
668
+ hostCwd: "/Users/stan/Dev/myproject",
669
+ containerCwd: "/workspace",
670
+ state: "running",
671
+ });
672
+
673
+ mockExecSync.mockImplementation((cmd: string) => {
674
+ if (!cmd.startsWith("docker exec abc123def456 sh -lc ")) {
675
+ throw new Error(`unexpected command: ${cmd}`);
676
+ }
677
+ if (cmd.includes("--abbrev-ref HEAD")) return "container-branch\n";
678
+ if (cmd.includes("--git-dir")) return ".git\n";
679
+ if (cmd.includes("--show-toplevel")) return "/workspace/packages/api\n";
680
+ if (cmd.includes("--left-right --count")) return "0\t0\n";
681
+ throw new Error(`unknown git cmd: ${cmd}`);
682
+ });
683
+
684
+ const cli = makeCliSocket("s1");
685
+ bridge.handleCLIOpen(cli, "s1");
686
+ await bridge.handleCLIMessage(cli, makeInitMsg({ cwd: "/workspace" }));
687
+
688
+ const state = bridge.getSession("s1")!.state;
689
+ expect(state.repo_root).toBe("/Users/stan/Dev/myproject/packages/api");
690
+ expect(getContainerSpy).toHaveBeenCalledWith("s1");
691
+ getContainerSpy.mockRestore();
692
+ });
693
+
694
+ it("handleCLIMessage: system.init resolves git info via execSync", async () => {
695
+ mockExecSync.mockImplementation((cmd: string) => {
696
+ if (cmd.includes("--abbrev-ref HEAD")) return "feat/test-branch\n";
697
+ if (cmd.includes("--show-toplevel")) return "/repo\n";
698
+ if (cmd.includes("--left-right --count")) return "2\t5\n";
699
+ throw new Error("unknown git cmd");
700
+ });
701
+
702
+ const cli = makeCliSocket("s1");
703
+ bridge.handleCLIOpen(cli, "s1");
704
+ await bridge.handleCLIMessage(cli, makeInitMsg());
705
+
706
+ const state = bridge.getSession("s1")!.state;
707
+ expect(state.git_branch).toBe("feat/test-branch");
708
+ expect(state.repo_root).toBe("/repo");
709
+ expect(state.git_ahead).toBe(5);
710
+ expect(state.git_behind).toBe(2);
711
+ });
712
+
713
+ it("handleCLIMessage: system.init resolves repo_root via --show-toplevel for standard repo", async () => {
714
+ mockExecSync.mockImplementation((cmd: string) => {
715
+ if (cmd.includes("--abbrev-ref HEAD")) return "main\n";
716
+ if (cmd.includes("--git-dir")) return ".git\n";
717
+ if (cmd.includes("--show-toplevel")) return "/home/user/myproject\n";
718
+ if (cmd.includes("--left-right --count")) return "0\t0\n";
719
+ throw new Error("unknown git cmd");
720
+ });
721
+
722
+ const cli = makeCliSocket("s1");
723
+ bridge.handleCLIOpen(cli, "s1");
724
+ await bridge.handleCLIMessage(cli, makeInitMsg({ cwd: "/home/user/myproject" }));
725
+
726
+ const state = bridge.getSession("s1")!.state;
727
+ expect(state.repo_root).toBe("/home/user/myproject");
728
+ });
729
+
730
+ it("handleCLIMessage: system.status updates compacting and permissionMode", async () => {
731
+ const cli = makeCliSocket("s1");
732
+ const browser = makeBrowserSocket("s1");
733
+ bridge.handleCLIOpen(cli, "s1");
734
+ bridge.handleBrowserOpen(browser, "s1");
735
+ browser.send.mockClear();
736
+
737
+ const statusMsg = JSON.stringify({
738
+ type: "system",
739
+ subtype: "status",
740
+ status: "compacting",
741
+ permissionMode: "plan",
742
+ uuid: "uuid-2",
743
+ session_id: "s1",
744
+ });
745
+
746
+ await bridge.handleCLIMessage(cli, statusMsg);
747
+
748
+ const state = bridge.getSession("s1")!.state;
749
+ expect(state.is_compacting).toBe(true);
750
+ expect(state.permissionMode).toBe("plan");
751
+
752
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
753
+ expect(calls).toContainEqual(expect.objectContaining({ type: "status_change", status: "compacting" }));
754
+ // When the CLI changes permissionMode via system.status, the server should
755
+ // broadcast a session_update so browsers sync their UI (e.g. plan toggle).
756
+ expect(calls).toContainEqual(expect.objectContaining({
757
+ type: "session_update",
758
+ session: expect.objectContaining({ permissionMode: "plan" }),
759
+ }));
760
+ });
761
+
762
+ it("handleCLIMessage: system.status does NOT broadcast session_update when permissionMode unchanged", async () => {
763
+ // Pre-set the session to "default" mode, then send a status with the same mode.
764
+ const cli = makeCliSocket("s1");
765
+ const browser = makeBrowserSocket("s1");
766
+ bridge.handleCLIOpen(cli, "s1");
767
+ bridge.handleBrowserOpen(browser, "s1");
768
+ browser.send.mockClear();
769
+
770
+ const statusMsg = JSON.stringify({
771
+ type: "system",
772
+ subtype: "status",
773
+ status: "idle",
774
+ permissionMode: "default",
775
+ uuid: "uuid-3",
776
+ session_id: "s1",
777
+ });
778
+
779
+ await bridge.handleCLIMessage(cli, statusMsg);
780
+
781
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
782
+ // Should NOT have a session_update for permissionMode since it didn't change.
783
+ const permUpdates = calls.filter(
784
+ (c: Record<string, unknown>) => c.type === "session_update" && (c.session as Record<string, unknown>)?.permissionMode,
785
+ );
786
+ expect(permUpdates).toHaveLength(0);
787
+ });
788
+
789
+ it("handleCLIMessage: system.status broadcasts session_update on plan→default (ExitPlanMode scenario)", async () => {
790
+ // Regression test for the exact bug: after ExitPlanMode approval, the CLI
791
+ // sends system.status with permissionMode:"default" but the browser was
792
+ // never notified, leaving the plan toggle stuck.
793
+ const cli = makeCliSocket("s1");
794
+ const browser = makeBrowserSocket("s1");
795
+ bridge.handleCLIOpen(cli, "s1");
796
+ bridge.handleBrowserOpen(browser, "s1");
797
+
798
+ // First put the session into plan mode
799
+ await bridge.handleCLIMessage(cli, JSON.stringify({
800
+ type: "system", subtype: "status", status: "idle",
801
+ permissionMode: "plan", uuid: "uuid-plan", session_id: "s1",
802
+ }));
803
+ expect(bridge.getSession("s1")!.state.permissionMode).toBe("plan");
804
+ browser.send.mockClear();
805
+
806
+ // CLI exits plan mode (ExitPlanMode scenario)
807
+ await bridge.handleCLIMessage(cli, JSON.stringify({
808
+ type: "system", subtype: "status", status: "idle",
809
+ permissionMode: "default", uuid: "uuid-exit-plan", session_id: "s1",
810
+ }));
811
+
812
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
813
+ expect(calls).toContainEqual(expect.objectContaining({
814
+ type: "session_update",
815
+ session: expect.objectContaining({ permissionMode: "default" }),
816
+ }));
817
+ expect(bridge.getSession("s1")!.state.permissionMode).toBe("default");
818
+ });
819
+
820
+ it("handleCLIMessage: forwards compact_boundary as system_event and persists it", async () => {
821
+ const cli = makeCliSocket("s1");
822
+ const browser = makeBrowserSocket("s1");
823
+ bridge.handleCLIOpen(cli, "s1");
824
+ bridge.handleBrowserOpen(browser, "s1");
825
+ browser.send.mockClear();
826
+
827
+ await bridge.handleCLIMessage(cli, JSON.stringify({
828
+ type: "system",
829
+ subtype: "compact_boundary",
830
+ compact_metadata: { trigger: "auto", pre_tokens: 4096 },
831
+ uuid: "uuid-compact",
832
+ session_id: "s1",
833
+ }));
834
+
835
+ const session = bridge.getSession("s1")!;
836
+ expect(session.messageHistory).toHaveLength(1);
837
+ expect(session.messageHistory[0]).toMatchObject({
838
+ type: "system_event",
839
+ event: {
840
+ subtype: "compact_boundary",
841
+ },
842
+ });
843
+
844
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
845
+ const forwarded = calls.find((c: any) => c.type === "system_event");
846
+ expect(forwarded).toBeDefined();
847
+ expect(forwarded.event.subtype).toBe("compact_boundary");
848
+ });
849
+
850
+ it("handleCLIMessage: forwards hook_progress as system_event without persisting history", async () => {
851
+ const cli = makeCliSocket("s1");
852
+ const browser = makeBrowserSocket("s1");
853
+ bridge.handleCLIOpen(cli, "s1");
854
+ bridge.handleBrowserOpen(browser, "s1");
855
+ browser.send.mockClear();
856
+
857
+ await bridge.handleCLIMessage(cli, JSON.stringify({
858
+ type: "system",
859
+ subtype: "hook_progress",
860
+ hook_id: "hk-1",
861
+ hook_name: "lint",
862
+ hook_event: "post_tool_use",
863
+ stdout: "running",
864
+ stderr: "",
865
+ output: "running",
866
+ uuid: "uuid-hook-progress",
867
+ session_id: "s1",
868
+ }));
869
+
870
+ const session = bridge.getSession("s1")!;
871
+ expect(session.messageHistory).toHaveLength(0);
872
+
873
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
874
+ const forwarded = calls.find((c: any) => c.type === "system_event");
875
+ expect(forwarded).toBeDefined();
876
+ expect(forwarded.event.subtype).toBe("hook_progress");
877
+ });
878
+
879
+ it("handleCLIClose: disconnects backendAdapter and broadcasts cli_disconnected", () => {
880
+ vi.useFakeTimers();
881
+ const cli = makeCliSocket("s1");
882
+ const browser = makeBrowserSocket("s1");
883
+ bridge.handleCLIOpen(cli, "s1");
884
+ bridge.handleBrowserOpen(browser, "s1");
885
+ browser.send.mockClear();
886
+
887
+ bridge.handleCLIClose(cli);
888
+
889
+ const session = bridge.getSession("s1")!;
890
+ expect(session.backendAdapter?.isConnected()).toBe(false);
891
+ expect(bridge.isCliConnected("s1")).toBe(false);
892
+
893
+ // Advance past disconnect debounce (15s)
894
+ vi.advanceTimersByTime(16_000);
895
+
896
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
897
+ expect(calls).toContainEqual(expect.objectContaining({ type: "cli_disconnected" }));
898
+ vi.useRealTimers();
899
+ });
900
+
901
+ it("handleCLIClose: cancels pending permissions", async () => {
902
+ vi.useFakeTimers();
903
+ const cli = makeCliSocket("s1");
904
+ const browser = makeBrowserSocket("s1");
905
+ bridge.handleCLIOpen(cli, "s1");
906
+ bridge.handleBrowserOpen(browser, "s1");
907
+
908
+ // Simulate a pending permission request
909
+ const controlReq = JSON.stringify({
910
+ type: "control_request",
911
+ request_id: "req-1",
912
+ request: {
913
+ subtype: "can_use_tool",
914
+ tool_name: "Bash",
915
+ input: { command: "rm -rf /" },
916
+ tool_use_id: "tu-1",
917
+ },
918
+ });
919
+ await bridge.handleCLIMessage(cli, controlReq);
920
+ browser.send.mockClear();
921
+
922
+ bridge.handleCLIClose(cli);
923
+
924
+ // Advance past disconnect debounce (15s)
925
+ vi.advanceTimersByTime(16_000);
926
+
927
+ const session = bridge.getSession("s1")!;
928
+ expect(session.pendingPermissions.size).toBe(0);
929
+
930
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
931
+ const cancelMsg = calls.find((c: any) => c.type === "permission_cancelled");
932
+ expect(cancelMsg).toBeDefined();
933
+ expect(cancelMsg.request_id).toBe("req-1");
934
+ vi.useRealTimers();
935
+ });
936
+
937
+ it("handleCLIClose: ignores stale socket close (new WS opened before old closed)", () => {
938
+ const cli1 = makeCliSocket("s1");
939
+ const cli2 = makeCliSocket("s1");
940
+ const browser = makeBrowserSocket("s1");
941
+
942
+ bridge.handleCLIOpen(cli1, "s1");
943
+ bridge.handleBrowserOpen(browser, "s1");
944
+
945
+ // CLI reconnects — new socket opens before old one closes
946
+ bridge.handleCLIOpen(cli2, "s1");
947
+ browser.send.mockClear();
948
+
949
+ // Stale close event fires from cli1
950
+ bridge.handleCLIClose(cli1);
951
+
952
+ // backendAdapter should still be connected via cli2, not disconnected
953
+ const session = bridge.getSession("s1")!;
954
+ expect(session.backendAdapter).not.toBeNull();
955
+ expect(session.backendAdapter?.isConnected()).toBe(true);
956
+ expect(bridge.isCliConnected("s1")).toBe(true);
957
+
958
+ // No cli_disconnected should be broadcast
959
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
960
+ expect(calls.find((c: any) => c.type === "cli_disconnected")).toBeUndefined();
961
+ });
962
+
963
+ it("handleCLIClose: debounces disconnect notification", () => {
964
+ vi.useFakeTimers();
965
+ const cli = makeCliSocket("s1");
966
+ const browser = makeBrowserSocket("s1");
967
+
968
+ bridge.handleCLIOpen(cli, "s1");
969
+ bridge.handleBrowserOpen(browser, "s1");
970
+ browser.send.mockClear();
971
+
972
+ bridge.handleCLIClose(cli);
973
+
974
+ // Immediately after close: no cli_disconnected broadcast yet
975
+ const immediateCalls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
976
+ expect(immediateCalls.find((c: any) => c.type === "cli_disconnected")).toBeUndefined();
977
+
978
+ // After debounce period: cli_disconnected should be broadcast
979
+ vi.advanceTimersByTime(16_000);
980
+
981
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
982
+ expect(calls).toContainEqual(expect.objectContaining({ type: "cli_disconnected" }));
983
+
984
+ vi.useRealTimers();
985
+ });
986
+
987
+ it("handleCLIClose: debounce cancelled by reconnect", () => {
988
+ vi.useFakeTimers();
989
+ const cli1 = makeCliSocket("s1");
990
+ const cli2 = makeCliSocket("s1");
991
+ const browser = makeBrowserSocket("s1");
992
+
993
+ bridge.handleCLIOpen(cli1, "s1");
994
+ bridge.handleBrowserOpen(browser, "s1");
995
+ browser.send.mockClear();
996
+
997
+ // CLI disconnects
998
+ bridge.handleCLIClose(cli1);
999
+
1000
+ // CLI reconnects within debounce window
1001
+ vi.advanceTimersByTime(5_000);
1002
+ bridge.handleCLIOpen(cli2, "s1");
1003
+ browser.send.mockClear();
1004
+
1005
+ // Debounce timer fires — should NOT broadcast disconnect
1006
+ vi.advanceTimersByTime(16_000);
1007
+
1008
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1009
+ expect(calls.find((c: any) => c.type === "cli_disconnected")).toBeUndefined();
1010
+ expect(bridge.isCliConnected("s1")).toBe(true);
1011
+
1012
+ vi.useRealTimers();
1013
+ });
1014
+
1015
+ it("Codex adapter disconnect: uses debounce and broadcasts cli_disconnected only after 5s", () => {
1016
+ vi.useFakeTimers();
1017
+ const session = bridge.getOrCreateSession("s1", "codex");
1018
+ const browser = makeBrowserSocket("s1");
1019
+ bridge.handleBrowserOpen(browser, "s1");
1020
+
1021
+ // Create a mock Codex adapter and capture the onDisconnect callback
1022
+ let disconnectCb: (() => void) | undefined;
1023
+ const adapter = {
1024
+ isConnected: () => false,
1025
+ send: () => true,
1026
+ disconnect: async () => {},
1027
+ onBrowserMessage: () => {},
1028
+ onSessionMeta: () => {},
1029
+ onDisconnect: (cb: () => void) => { disconnectCb = cb; },
1030
+ onInitError: () => {},
1031
+ };
1032
+
1033
+ bridge.attachBackendAdapter("s1", adapter as any, "codex");
1034
+ browser.send.mockClear();
1035
+
1036
+ // Trigger disconnect
1037
+ disconnectCb!();
1038
+
1039
+ // Immediately after disconnect: should transition to "reconnecting" but NOT broadcast cli_disconnected yet
1040
+ expect(session.stateMachine.phase).toBe("reconnecting");
1041
+ const immediateCalls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1042
+ expect(immediateCalls.find((c: any) => c.type === "cli_disconnected")).toBeUndefined();
1043
+ // session_phase: reconnecting should be broadcast (sets cliReconnecting=true on frontend)
1044
+ expect(immediateCalls).toContainEqual(expect.objectContaining({ type: "session_phase", phase: "reconnecting" }));
1045
+
1046
+ // After 5s debounce: cli_disconnected should be broadcast
1047
+ vi.advanceTimersByTime(5_000);
1048
+ const laterCalls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1049
+ expect(laterCalls).toContainEqual(expect.objectContaining({ type: "cli_disconnected" }));
1050
+
1051
+ vi.useRealTimers();
1052
+ });
1053
+
1054
+ it("Codex adapter disconnect: debounce is cancelled when new adapter attaches", () => {
1055
+ vi.useFakeTimers();
1056
+ bridge.getOrCreateSession("s1", "codex");
1057
+ const browser = makeBrowserSocket("s1");
1058
+ bridge.handleBrowserOpen(browser, "s1");
1059
+
1060
+ let disconnectCb: (() => void) | undefined;
1061
+ const adapter1 = {
1062
+ isConnected: () => false,
1063
+ send: () => true,
1064
+ disconnect: async () => {},
1065
+ onBrowserMessage: () => {},
1066
+ onSessionMeta: () => {},
1067
+ onDisconnect: (cb: () => void) => { disconnectCb = cb; },
1068
+ onInitError: () => {},
1069
+ };
1070
+
1071
+ bridge.attachBackendAdapter("s1", adapter1 as any, "codex");
1072
+ browser.send.mockClear();
1073
+
1074
+ // Trigger disconnect
1075
+ disconnectCb!();
1076
+
1077
+ // Advance 2s (before debounce fires)
1078
+ vi.advanceTimersByTime(2_000);
1079
+
1080
+ // Attach a new adapter (simulating relaunch)
1081
+ const adapter2 = {
1082
+ isConnected: () => true,
1083
+ send: () => true,
1084
+ disconnect: async () => {},
1085
+ onBrowserMessage: () => {},
1086
+ onSessionMeta: () => {},
1087
+ onDisconnect: () => {},
1088
+ onInitError: () => {},
1089
+ };
1090
+ bridge.attachBackendAdapter("s1", adapter2 as any, "codex");
1091
+ browser.send.mockClear();
1092
+
1093
+ // Advance past the original debounce time
1094
+ vi.advanceTimersByTime(5_000);
1095
+
1096
+ // cli_disconnected should NOT have been broadcast (debounce was cancelled)
1097
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1098
+ expect(calls.find((c: any) => c.type === "cli_disconnected")).toBeUndefined();
1099
+
1100
+ vi.useRealTimers();
1101
+ });
1102
+
1103
+ it("Codex adapter disconnect: emits session:relaunch-needed regardless of browser count", () => {
1104
+ vi.useFakeTimers();
1105
+ // Create session with NO browsers connected
1106
+ bridge.getOrCreateSession("s1", "codex");
1107
+
1108
+ let disconnectCb: (() => void) | undefined;
1109
+ const adapter = {
1110
+ isConnected: () => false,
1111
+ send: () => true,
1112
+ disconnect: async () => {},
1113
+ onBrowserMessage: () => {},
1114
+ onSessionMeta: () => {},
1115
+ onDisconnect: (cb: () => void) => { disconnectCb = cb; },
1116
+ onInitError: () => {},
1117
+ };
1118
+
1119
+ const relaunchCb = vi.fn();
1120
+ companionBus.on("session:relaunch-needed", ({ sessionId }) => relaunchCb(sessionId));
1121
+
1122
+ bridge.attachBackendAdapter("s1", adapter as any, "codex");
1123
+
1124
+ // Trigger disconnect (no browsers connected)
1125
+ disconnectCb!();
1126
+
1127
+ // Advance past debounce
1128
+ vi.advanceTimersByTime(5_000);
1129
+
1130
+ // Should still emit relaunch-needed even without browsers
1131
+ expect(relaunchCb).toHaveBeenCalledWith("s1");
1132
+
1133
+ vi.useRealTimers();
1134
+ });
1135
+ });
1136
+
1137
+ // ─── Browser handlers ────────────────────────────────────────────────────────
1138
+
1139
+ describe("Browser handlers", () => {
1140
+ it("handleBrowserOpen: adds to set and sends session_init", () => {
1141
+ bridge.getOrCreateSession("s1");
1142
+ const browser = makeBrowserSocket("s1");
1143
+
1144
+ bridge.handleBrowserOpen(browser, "s1");
1145
+
1146
+ const session = bridge.getSession("s1")!;
1147
+ expect(session.browserSockets.has(browser)).toBe(true);
1148
+
1149
+ expect(browser.send).toHaveBeenCalled();
1150
+ const firstMsg = JSON.parse(browser.send.mock.calls[0][0]);
1151
+ expect(firstMsg.type).toBe("session_init");
1152
+ expect(firstMsg.session.session_id).toBe("s1");
1153
+ });
1154
+
1155
+ it("handleBrowserOpen: refreshes git branch before sending session snapshot", () => {
1156
+ mockExecSync.mockImplementation((cmd: string) => {
1157
+ if (cmd.includes("--abbrev-ref HEAD")) return "feat/dynamic-branch\n";
1158
+ if (cmd.includes("--git-dir")) return ".git\n";
1159
+ if (cmd.includes("--show-toplevel")) return "/repo\n";
1160
+ if (cmd.includes("--left-right --count")) return "0\t0\n";
1161
+ throw new Error("unknown git cmd");
1162
+ });
1163
+
1164
+ const session = bridge.getOrCreateSession("s1");
1165
+ session.state.cwd = "/repo";
1166
+ session.state.git_branch = "main";
1167
+
1168
+ const gitInfoCb = vi.fn();
1169
+ companionBus.on("session:git-info-ready", ({ sessionId, cwd, branch }) => gitInfoCb(sessionId, cwd, branch));
1170
+
1171
+ const browser = makeBrowserSocket("s1");
1172
+ bridge.handleBrowserOpen(browser, "s1");
1173
+
1174
+ const firstMsg = JSON.parse(browser.send.mock.calls[0][0]);
1175
+ expect(firstMsg.type).toBe("session_init");
1176
+ expect(firstMsg.session.git_branch).toBe("feat/dynamic-branch");
1177
+ expect(gitInfoCb).toHaveBeenCalledWith("s1", "/repo", "feat/dynamic-branch");
1178
+ });
1179
+
1180
+ it("handleBrowserOpen: replays message history", async () => {
1181
+ // First populate some history
1182
+ mockExecSync.mockImplementation(() => {
1183
+ throw new Error("not a git repo");
1184
+ });
1185
+
1186
+ const cli = makeCliSocket("s1");
1187
+ bridge.handleCLIOpen(cli, "s1");
1188
+
1189
+ const assistantMsg = JSON.stringify({
1190
+ type: "assistant",
1191
+ message: {
1192
+ id: "msg-1",
1193
+ type: "message",
1194
+ role: "assistant",
1195
+ model: "claude-sonnet-4-6",
1196
+ content: [{ type: "text", text: "Hello!" }],
1197
+ stop_reason: null,
1198
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1199
+ },
1200
+ parent_tool_use_id: null,
1201
+ uuid: "uuid-2",
1202
+ session_id: "s1",
1203
+ });
1204
+ await bridge.handleCLIMessage(cli, assistantMsg);
1205
+
1206
+ // Now connect a new browser
1207
+ const browser = makeBrowserSocket("s1");
1208
+ bridge.handleBrowserOpen(browser, "s1");
1209
+
1210
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1211
+ const historyMsg = calls.find((c: any) => c.type === "message_history");
1212
+ expect(historyMsg).toBeDefined();
1213
+ expect(historyMsg.messages).toHaveLength(1);
1214
+ expect(historyMsg.messages[0].type).toBe("assistant");
1215
+ });
1216
+
1217
+ it("handleBrowserOpen: sends pending permissions", async () => {
1218
+ const cli = makeCliSocket("s1");
1219
+ bridge.handleCLIOpen(cli, "s1");
1220
+
1221
+ // Create a pending permission
1222
+ const controlReq = JSON.stringify({
1223
+ type: "control_request",
1224
+ request_id: "req-1",
1225
+ request: {
1226
+ subtype: "can_use_tool",
1227
+ tool_name: "Edit",
1228
+ input: { file_path: "/test.ts" },
1229
+ tool_use_id: "tu-1",
1230
+ },
1231
+ });
1232
+ await bridge.handleCLIMessage(cli, controlReq);
1233
+
1234
+ // Now connect a browser
1235
+ const browser = makeBrowserSocket("s1");
1236
+ bridge.handleBrowserOpen(browser, "s1");
1237
+
1238
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1239
+ const permMsg = calls.find((c: any) => c.type === "permission_request");
1240
+ expect(permMsg).toBeDefined();
1241
+ expect(permMsg.request.tool_name).toBe("Edit");
1242
+ expect(permMsg.request.request_id).toBe("req-1");
1243
+ });
1244
+
1245
+ it("handleBrowserOpen: triggers relaunch callback when CLI is dead", () => {
1246
+ const relaunchCb = vi.fn();
1247
+ companionBus.on("session:relaunch-needed", ({ sessionId }) => relaunchCb(sessionId));
1248
+
1249
+ bridge.getOrCreateSession("s1");
1250
+ const browser = makeBrowserSocket("s1");
1251
+ bridge.handleBrowserOpen(browser, "s1");
1252
+
1253
+ expect(relaunchCb).toHaveBeenCalledWith("s1");
1254
+
1255
+ // Also sends cli_disconnected
1256
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1257
+ const disconnectedMsg = calls.find((c: any) => c.type === "cli_disconnected");
1258
+ expect(disconnectedMsg).toBeDefined();
1259
+ });
1260
+
1261
+ it("handleBrowserOpen: does NOT relaunch when Codex adapter is attached but still initializing", () => {
1262
+ const relaunchCb = vi.fn();
1263
+ companionBus.on("session:relaunch-needed", ({ sessionId }) => relaunchCb(sessionId));
1264
+
1265
+ const session = bridge.getOrCreateSession("s1", "codex");
1266
+ session.backendAdapter = { isConnected: () => false, send: () => false, disconnect: async () => {}, onBrowserMessage: () => {}, onSessionMeta: () => {}, onDisconnect: () => {} } as any;
1267
+
1268
+ const browser = makeBrowserSocket("s1");
1269
+ bridge.handleBrowserOpen(browser, "s1");
1270
+
1271
+ expect(relaunchCb).not.toHaveBeenCalled();
1272
+
1273
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1274
+ const disconnectedMsg = calls.find((c: any) => c.type === "cli_disconnected");
1275
+ expect(disconnectedMsg).toBeUndefined();
1276
+ });
1277
+
1278
+ it("handleBrowserClose: removes from set", () => {
1279
+ const browser = makeBrowserSocket("s1");
1280
+ bridge.handleBrowserOpen(browser, "s1");
1281
+ expect(bridge.getSession("s1")!.browserSockets.has(browser)).toBe(true);
1282
+
1283
+ bridge.handleBrowserClose(browser);
1284
+ expect(bridge.getSession("s1")!.browserSockets.has(browser)).toBe(false);
1285
+ });
1286
+
1287
+ it("session_subscribe: replays buffered sequenced events after last_seq", async () => {
1288
+ const cli = makeCliSocket("s1");
1289
+ bridge.handleCLIOpen(cli, "s1");
1290
+
1291
+ // Generate replayable events while no browser is connected.
1292
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1293
+ type: "stream_event",
1294
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "a" } },
1295
+ parent_tool_use_id: null,
1296
+ uuid: "u1",
1297
+ session_id: "s1",
1298
+ }));
1299
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1300
+ type: "stream_event",
1301
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "b" } },
1302
+ parent_tool_use_id: null,
1303
+ uuid: "u2",
1304
+ session_id: "s1",
1305
+ }));
1306
+
1307
+ const browser = makeBrowserSocket("s1");
1308
+ bridge.handleBrowserOpen(browser, "s1");
1309
+ browser.send.mockClear();
1310
+
1311
+ // Ask for replay after seq=2 (session_phase + cli_connected). Both stream events should replay.
1312
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1313
+ type: "session_subscribe",
1314
+ last_seq: 2,
1315
+ }));
1316
+
1317
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1318
+ const replay = calls.find((c: any) => c.type === "event_replay");
1319
+ expect(replay).toBeDefined();
1320
+ expect(replay.events).toHaveLength(2);
1321
+ expect(replay.events[0].seq).toBe(3);
1322
+ expect(replay.events[0].message.type).toBe("stream_event");
1323
+ expect(replay.events[1].message.type).toBe("stream_event");
1324
+ });
1325
+
1326
+ it("session_subscribe: sends full message_history on first subscribe even without a replay gap", async () => {
1327
+ // A brand-new browser tab starts with last_seq=0 and needs the persisted
1328
+ // message history, including user messages that are never sequenced in the
1329
+ // event buffer. Without this bootstrap payload, Codex sessions can reopen
1330
+ // without their first user prompt in chat.
1331
+ const session = bridge.getOrCreateSession("s1", "codex");
1332
+ session.messageHistory.push({
1333
+ type: "user_message",
1334
+ id: "user-1",
1335
+ content: "first prompt",
1336
+ timestamp: 1000,
1337
+ });
1338
+ session.messageHistory.push({
1339
+ type: "assistant",
1340
+ message: {
1341
+ id: "assistant-1",
1342
+ type: "message",
1343
+ role: "assistant",
1344
+ model: "gpt-5.4",
1345
+ content: [{ type: "text", text: "reply" }],
1346
+ stop_reason: "end_turn",
1347
+ usage: {
1348
+ input_tokens: 1,
1349
+ output_tokens: 1,
1350
+ cache_creation_input_tokens: 0,
1351
+ cache_read_input_tokens: 0,
1352
+ },
1353
+ },
1354
+ parent_tool_use_id: null,
1355
+ timestamp: 2000,
1356
+ });
1357
+ session.eventBuffer.push({
1358
+ seq: 1,
1359
+ message: {
1360
+ type: "assistant",
1361
+ message: {
1362
+ id: "assistant-1",
1363
+ type: "message",
1364
+ role: "assistant",
1365
+ model: "gpt-5.4",
1366
+ content: [{ type: "text", text: "reply" }],
1367
+ stop_reason: "end_turn",
1368
+ usage: {
1369
+ input_tokens: 1,
1370
+ output_tokens: 1,
1371
+ cache_creation_input_tokens: 0,
1372
+ cache_read_input_tokens: 0,
1373
+ },
1374
+ },
1375
+ parent_tool_use_id: null,
1376
+ timestamp: 2000,
1377
+ },
1378
+ });
1379
+ session.eventBuffer.push({
1380
+ seq: 2,
1381
+ message: {
1382
+ type: "stream_event",
1383
+ event: {
1384
+ type: "content_block_delta",
1385
+ delta: { type: "text_delta", text: "stream-only" },
1386
+ },
1387
+ parent_tool_use_id: null,
1388
+ },
1389
+ });
1390
+ session.nextEventSeq = 3;
1391
+
1392
+ const browser = makeBrowserSocket("s1");
1393
+ bridge.handleBrowserOpen(browser, "s1");
1394
+ browser.send.mockClear();
1395
+
1396
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1397
+ type: "session_subscribe",
1398
+ last_seq: 0,
1399
+ }));
1400
+
1401
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1402
+ const historyMsg = calls.find((c: any) => c.type === "message_history");
1403
+ expect(historyMsg).toBeDefined();
1404
+ expect(historyMsg.messages).toHaveLength(2);
1405
+ expect(historyMsg.messages.some((m: any) => m.type === "user_message")).toBe(true);
1406
+ expect(historyMsg.messages.some((m: any) => m.type === "assistant")).toBe(true);
1407
+
1408
+ const replayMsg = calls.find((c: any) => c.type === "event_replay");
1409
+ expect(replayMsg).toBeDefined();
1410
+ expect(replayMsg.events).toHaveLength(1);
1411
+ expect(replayMsg.events[0].message.type).toBe("stream_event");
1412
+ });
1413
+
1414
+ it("session_subscribe: falls back to message_history when last_seq is older than buffer window", async () => {
1415
+ const cli = makeCliSocket("s1");
1416
+ bridge.handleCLIOpen(cli, "s1");
1417
+
1418
+ // Populate history so fallback payload has content.
1419
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1420
+ type: "assistant",
1421
+ message: {
1422
+ id: "hist-1",
1423
+ type: "message",
1424
+ role: "assistant",
1425
+ model: "claude-sonnet-4-6",
1426
+ content: [{ type: "text", text: "from history" }],
1427
+ stop_reason: "end_turn",
1428
+ usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1429
+ },
1430
+ parent_tool_use_id: null,
1431
+ uuid: "hist-u1",
1432
+ session_id: "s1",
1433
+ }));
1434
+
1435
+ // Generate several stream events, then trim the first one from in-memory buffer.
1436
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1437
+ type: "stream_event",
1438
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "1" } },
1439
+ parent_tool_use_id: null,
1440
+ uuid: "se-u1",
1441
+ session_id: "s1",
1442
+ }));
1443
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1444
+ type: "stream_event",
1445
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "2" } },
1446
+ parent_tool_use_id: null,
1447
+ uuid: "se-u2",
1448
+ session_id: "s1",
1449
+ }));
1450
+ const session = bridge.getSession("s1")!;
1451
+ session.eventBuffer.shift();
1452
+ session.eventBuffer.shift(); // force earliest seq high enough to create a gap for last_seq=1
1453
+
1454
+ const browser = makeBrowserSocket("s1");
1455
+ bridge.handleBrowserOpen(browser, "s1");
1456
+ browser.send.mockClear();
1457
+
1458
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1459
+ type: "session_subscribe",
1460
+ last_seq: 1,
1461
+ }));
1462
+
1463
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1464
+ const historyMsg = calls.find((c: any) => c.type === "message_history");
1465
+ expect(historyMsg).toBeDefined();
1466
+ expect(historyMsg.messages.some((m: any) => m.type === "assistant")).toBe(true);
1467
+ const replayMsg = calls.find((c: any) => c.type === "event_replay");
1468
+ expect(replayMsg).toBeDefined();
1469
+ expect(replayMsg.events.some((e: any) => e.message.type === "stream_event")).toBe(true);
1470
+ });
1471
+
1472
+ it("session_subscribe: sends ground-truth status_change=idle after event_replay when last history is result", async () => {
1473
+ // When the CLI finished (result in messageHistory), the server should send
1474
+ // a status_change after event_replay so the browser clears stale "running" state.
1475
+ const cli = makeCliSocket("s1");
1476
+ bridge.handleCLIOpen(cli, "s1");
1477
+
1478
+ // Simulate a completed turn: assistant → result in history
1479
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1480
+ type: "assistant",
1481
+ message: {
1482
+ id: "a1",
1483
+ type: "message",
1484
+ role: "assistant",
1485
+ model: "claude-sonnet-4-6",
1486
+ content: [{ type: "text", text: "hello" }],
1487
+ stop_reason: "end_turn",
1488
+ usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1489
+ },
1490
+ parent_tool_use_id: null,
1491
+ uuid: "u1",
1492
+ session_id: "s1",
1493
+ }));
1494
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1495
+ type: "result",
1496
+ is_error: false,
1497
+ total_cost_usd: 0.01,
1498
+ num_turns: 1,
1499
+ session_id: "s1",
1500
+ }));
1501
+
1502
+ const browser = makeBrowserSocket("s1");
1503
+ bridge.handleBrowserOpen(browser, "s1");
1504
+ browser.send.mockClear();
1505
+
1506
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1507
+ type: "session_subscribe",
1508
+ last_seq: 0,
1509
+ }));
1510
+
1511
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1512
+ // Last message should be a status_change with idle
1513
+ const statusMsg = calls.filter((c: any) => c.type === "status_change");
1514
+ expect(statusMsg.length).toBeGreaterThanOrEqual(1);
1515
+ const lastStatus = statusMsg[statusMsg.length - 1];
1516
+ expect(lastStatus.status).toBe("idle");
1517
+ });
1518
+
1519
+ it("session_subscribe: sends ground-truth status_change=running after event_replay when last history is assistant", async () => {
1520
+ // When the CLI is mid-turn (assistant in messageHistory but no result yet),
1521
+ // the ground-truth status should be "running".
1522
+ const cli = makeCliSocket("s1");
1523
+ bridge.handleCLIOpen(cli, "s1");
1524
+
1525
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1526
+ type: "assistant",
1527
+ message: {
1528
+ id: "a1",
1529
+ type: "message",
1530
+ role: "assistant",
1531
+ model: "claude-sonnet-4-6",
1532
+ content: [{ type: "text", text: "working on it" }],
1533
+ stop_reason: "end_turn",
1534
+ usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1535
+ },
1536
+ parent_tool_use_id: null,
1537
+ uuid: "u1",
1538
+ session_id: "s1",
1539
+ }));
1540
+
1541
+ const browser = makeBrowserSocket("s1");
1542
+ bridge.handleBrowserOpen(browser, "s1");
1543
+ browser.send.mockClear();
1544
+
1545
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1546
+ type: "session_subscribe",
1547
+ last_seq: 0,
1548
+ }));
1549
+
1550
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1551
+ const statusMsg = calls.filter((c: any) => c.type === "status_change");
1552
+ expect(statusMsg.length).toBeGreaterThanOrEqual(1);
1553
+ const lastStatus = statusMsg[statusMsg.length - 1];
1554
+ expect(lastStatus.status).toBe("running");
1555
+ });
1556
+
1557
+ it("session_subscribe: sends status_change=idle in gap path when session completed", async () => {
1558
+ // Even when falling back to message_history + transient replay,
1559
+ // a trailing status_change should correct stale state.
1560
+ const cli = makeCliSocket("s1");
1561
+ bridge.handleCLIOpen(cli, "s1");
1562
+
1563
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1564
+ type: "assistant",
1565
+ message: {
1566
+ id: "a1",
1567
+ type: "message",
1568
+ role: "assistant",
1569
+ model: "claude-sonnet-4-6",
1570
+ content: [{ type: "text", text: "done" }],
1571
+ stop_reason: "end_turn",
1572
+ usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1573
+ },
1574
+ parent_tool_use_id: null,
1575
+ uuid: "u1",
1576
+ session_id: "s1",
1577
+ }));
1578
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1579
+ type: "result",
1580
+ is_error: false,
1581
+ total_cost_usd: 0.01,
1582
+ num_turns: 1,
1583
+ session_id: "s1",
1584
+ }));
1585
+ // Add a stream event and then force a gap by trimming the buffer
1586
+ await bridge.handleCLIMessage(cli, JSON.stringify({
1587
+ type: "stream_event",
1588
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "x" } },
1589
+ parent_tool_use_id: null,
1590
+ uuid: "se1",
1591
+ session_id: "s1",
1592
+ }));
1593
+ const session = bridge.getSession("s1")!;
1594
+ session.eventBuffer.shift(); // force a gap
1595
+
1596
+ const browser = makeBrowserSocket("s1");
1597
+ bridge.handleBrowserOpen(browser, "s1");
1598
+ browser.send.mockClear();
1599
+
1600
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1601
+ type: "session_subscribe",
1602
+ last_seq: 1,
1603
+ }));
1604
+
1605
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1606
+ const statusMsg = calls.filter((c: any) => c.type === "status_change");
1607
+ expect(statusMsg.length).toBeGreaterThanOrEqual(1);
1608
+ expect(statusMsg[statusMsg.length - 1].status).toBe("idle");
1609
+ });
1610
+
1611
+ it("session_ack: updates lastAckSeq for the session", () => {
1612
+ const browser = makeBrowserSocket("s1");
1613
+ bridge.handleBrowserOpen(browser, "s1");
1614
+
1615
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1616
+ type: "session_ack",
1617
+ last_seq: 42,
1618
+ }));
1619
+
1620
+ const session = bridge.getSession("s1")!;
1621
+ expect(session.lastAckSeq).toBe(42);
1622
+ });
1623
+ });
1624
+
1625
+ // ─── CLI message routing ─────────────────────────────────────────────────────
1626
+
1627
+ describe("CLI message routing", () => {
1628
+ let cli: ReturnType<typeof makeCliSocket>;
1629
+ let browser: ReturnType<typeof makeBrowserSocket>;
1630
+
1631
+ beforeEach(() => {
1632
+ cli = makeCliSocket("s1");
1633
+ browser = makeBrowserSocket("s1");
1634
+ bridge.handleCLIOpen(cli, "s1");
1635
+ bridge.handleBrowserOpen(browser, "s1");
1636
+ browser.send.mockClear();
1637
+ });
1638
+
1639
+ it("assistant: stores in history and broadcasts", async () => {
1640
+ const msg = JSON.stringify({
1641
+ type: "assistant",
1642
+ message: {
1643
+ id: "msg-1",
1644
+ type: "message",
1645
+ role: "assistant",
1646
+ model: "claude-sonnet-4-6",
1647
+ content: [{ type: "text", text: "Hello world!" }],
1648
+ stop_reason: "end_turn",
1649
+ usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1650
+ },
1651
+ parent_tool_use_id: null,
1652
+ uuid: "uuid-3",
1653
+ session_id: "s1",
1654
+ });
1655
+
1656
+ await bridge.handleCLIMessage(cli, msg);
1657
+
1658
+ const session = bridge.getSession("s1")!;
1659
+ expect(session.messageHistory).toHaveLength(1);
1660
+ expect(session.messageHistory[0].type).toBe("assistant");
1661
+
1662
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1663
+ const assistantBroadcast = calls.find((c: any) => c.type === "assistant");
1664
+ expect(assistantBroadcast).toBeDefined();
1665
+ expect(assistantBroadcast.message.content[0].text).toBe("Hello world!");
1666
+ expect(assistantBroadcast.parent_tool_use_id).toBeNull();
1667
+ });
1668
+
1669
+ it("result: updates cost/turns/context% and stores + broadcasts", async () => {
1670
+ const msg = JSON.stringify({
1671
+ type: "result",
1672
+ subtype: "success",
1673
+ is_error: false,
1674
+ result: "Done!",
1675
+ duration_ms: 5000,
1676
+ duration_api_ms: 4000,
1677
+ num_turns: 3,
1678
+ total_cost_usd: 0.05,
1679
+ stop_reason: "end_turn",
1680
+ usage: { input_tokens: 500, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1681
+ total_lines_added: 42,
1682
+ total_lines_removed: 10,
1683
+ uuid: "uuid-4",
1684
+ session_id: "s1",
1685
+ });
1686
+
1687
+ await bridge.handleCLIMessage(cli, msg);
1688
+
1689
+ const state = bridge.getSession("s1")!.state;
1690
+ expect(state.total_cost_usd).toBe(0.05);
1691
+ expect(state.num_turns).toBe(3);
1692
+ expect(state.total_lines_added).toBe(42);
1693
+ expect(state.total_lines_removed).toBe(10);
1694
+
1695
+ const session = bridge.getSession("s1")!;
1696
+ expect(session.messageHistory).toHaveLength(1);
1697
+ expect(session.messageHistory[0].type).toBe("result");
1698
+
1699
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1700
+ const resultBroadcast = calls.find((c: any) => c.type === "result");
1701
+ expect(resultBroadcast).toBeDefined();
1702
+ expect(resultBroadcast.data.total_cost_usd).toBe(0.05);
1703
+ });
1704
+
1705
+ it("result: refreshes git branch and broadcasts session_update when branch changes", async () => {
1706
+ mockExecSync.mockImplementation((cmd: string) => {
1707
+ if (cmd.includes("--abbrev-ref HEAD")) return "feat/new-branch\n";
1708
+ if (cmd.includes("--git-dir")) return ".git\n";
1709
+ if (cmd.includes("--show-toplevel")) return "/test\n";
1710
+ if (cmd.includes("--left-right --count")) return "0\t1\n";
1711
+ throw new Error("unknown git cmd");
1712
+ });
1713
+
1714
+ const session = bridge.getSession("s1")!;
1715
+ session.state.cwd = "/test";
1716
+ session.state.git_branch = "main";
1717
+
1718
+ const msg = JSON.stringify({
1719
+ type: "result",
1720
+ subtype: "success",
1721
+ is_error: false,
1722
+ result: "Done!",
1723
+ duration_ms: 5000,
1724
+ duration_api_ms: 4000,
1725
+ num_turns: 1,
1726
+ total_cost_usd: 0.01,
1727
+ stop_reason: "end_turn",
1728
+ usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1729
+ uuid: "uuid-refresh-git",
1730
+ session_id: "s1",
1731
+ });
1732
+
1733
+ await bridge.handleCLIMessage(cli, msg);
1734
+
1735
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1736
+ const updateMsg = calls.find((c: any) => c.type === "session_update");
1737
+ expect(updateMsg).toBeDefined();
1738
+ expect(updateMsg.session.git_branch).toBe("feat/new-branch");
1739
+ expect(updateMsg.session.git_ahead).toBe(1);
1740
+ expect(bridge.getSession("s1")!.state.git_branch).toBe("feat/new-branch");
1741
+ });
1742
+
1743
+ it("result: computes context_used_percent from modelUsage", async () => {
1744
+ const msg = JSON.stringify({
1745
+ type: "result",
1746
+ subtype: "success",
1747
+ is_error: false,
1748
+ duration_ms: 5000,
1749
+ duration_api_ms: 4000,
1750
+ num_turns: 1,
1751
+ total_cost_usd: 0.02,
1752
+ stop_reason: "end_turn",
1753
+ usage: { input_tokens: 500, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1754
+ modelUsage: {
1755
+ "claude-sonnet-4-6": {
1756
+ inputTokens: 8000,
1757
+ outputTokens: 2000,
1758
+ cacheReadInputTokens: 0,
1759
+ cacheCreationInputTokens: 0,
1760
+ contextWindow: 200000,
1761
+ maxOutputTokens: 16384,
1762
+ costUSD: 0.02,
1763
+ },
1764
+ },
1765
+ uuid: "uuid-5",
1766
+ session_id: "s1",
1767
+ });
1768
+
1769
+ await bridge.handleCLIMessage(cli, msg);
1770
+
1771
+ const state = bridge.getSession("s1")!.state;
1772
+ // (8000 + 2000) / 200000 * 100 = 5
1773
+ expect(state.context_used_percent).toBe(5);
1774
+ });
1775
+
1776
+ it("stream_event: broadcasts without storing", async () => {
1777
+ const msg = JSON.stringify({
1778
+ type: "stream_event",
1779
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "hi" } },
1780
+ parent_tool_use_id: null,
1781
+ uuid: "uuid-6",
1782
+ session_id: "s1",
1783
+ });
1784
+
1785
+ await bridge.handleCLIMessage(cli, msg);
1786
+
1787
+ const session = bridge.getSession("s1")!;
1788
+ expect(session.messageHistory).toHaveLength(0);
1789
+
1790
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1791
+ const streamEvent = calls.find((c: any) => c.type === "stream_event");
1792
+ expect(streamEvent).toBeDefined();
1793
+ expect(streamEvent.event.delta.text).toBe("hi");
1794
+ expect(streamEvent.parent_tool_use_id).toBeNull();
1795
+ });
1796
+
1797
+ it("control_request (can_use_tool): adds to pending and broadcasts", async () => {
1798
+ const msg = JSON.stringify({
1799
+ type: "control_request",
1800
+ request_id: "req-42",
1801
+ request: {
1802
+ subtype: "can_use_tool",
1803
+ tool_name: "Bash",
1804
+ input: { command: "ls -la" },
1805
+ description: "List files",
1806
+ tool_use_id: "tu-42",
1807
+ agent_id: "agent-1",
1808
+ permission_suggestions: [{ type: "addRules", rules: [{ toolName: "Bash" }], behavior: "allow", destination: "session" }],
1809
+ },
1810
+ });
1811
+
1812
+ await bridge.handleCLIMessage(cli, msg);
1813
+
1814
+ const session = bridge.getSession("s1")!;
1815
+ expect(session.pendingPermissions.size).toBe(1);
1816
+ const perm = session.pendingPermissions.get("req-42")!;
1817
+ expect(perm.tool_name).toBe("Bash");
1818
+ expect(perm.input).toEqual({ command: "ls -la" });
1819
+ expect(perm.description).toBe("List files");
1820
+ expect(perm.tool_use_id).toBe("tu-42");
1821
+ expect(perm.agent_id).toBe("agent-1");
1822
+ expect(perm.timestamp).toBeGreaterThan(0);
1823
+
1824
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1825
+ const permBroadcast = calls.find((c: any) => c.type === "permission_request");
1826
+ expect(permBroadcast).toBeDefined();
1827
+ expect(permBroadcast.request.request_id).toBe("req-42");
1828
+ expect(permBroadcast.request.tool_name).toBe("Bash");
1829
+ });
1830
+
1831
+ it("tool_progress: broadcasts", async () => {
1832
+ const msg = JSON.stringify({
1833
+ type: "tool_progress",
1834
+ tool_use_id: "tu-10",
1835
+ tool_name: "Bash",
1836
+ parent_tool_use_id: null,
1837
+ elapsed_time_seconds: 3.5,
1838
+ uuid: "uuid-7",
1839
+ session_id: "s1",
1840
+ });
1841
+
1842
+ await bridge.handleCLIMessage(cli, msg);
1843
+
1844
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1845
+ const progressMsg = calls.find((c: any) => c.type === "tool_progress");
1846
+ expect(progressMsg).toBeDefined();
1847
+ expect(progressMsg.tool_use_id).toBe("tu-10");
1848
+ expect(progressMsg.tool_name).toBe("Bash");
1849
+ expect(progressMsg.elapsed_time_seconds).toBe(3.5);
1850
+ });
1851
+
1852
+ it("tool_use_summary: broadcasts", async () => {
1853
+ const msg = JSON.stringify({
1854
+ type: "tool_use_summary",
1855
+ summary: "Ran bash command successfully",
1856
+ preceding_tool_use_ids: ["tu-10", "tu-11"],
1857
+ uuid: "uuid-8",
1858
+ session_id: "s1",
1859
+ });
1860
+
1861
+ await bridge.handleCLIMessage(cli, msg);
1862
+
1863
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1864
+ const summaryMsg = calls.find((c: any) => c.type === "tool_use_summary");
1865
+ expect(summaryMsg).toBeDefined();
1866
+ expect(summaryMsg.summary).toBe("Ran bash command successfully");
1867
+ expect(summaryMsg.tool_use_ids).toEqual(["tu-10", "tu-11"]);
1868
+ });
1869
+
1870
+ it("keep_alive: silently consumed, no broadcast", async () => {
1871
+ const msg = JSON.stringify({ type: "keep_alive" });
1872
+
1873
+ await bridge.handleCLIMessage(cli, msg);
1874
+
1875
+ expect(browser.send).not.toHaveBeenCalled();
1876
+ });
1877
+
1878
+ it("multi-line NDJSON: processes both lines", async () => {
1879
+ const line1 = JSON.stringify({
1880
+ type: "tool_progress",
1881
+ tool_use_id: "tu-a",
1882
+ tool_name: "Read",
1883
+ parent_tool_use_id: null,
1884
+ elapsed_time_seconds: 1,
1885
+ uuid: "uuid-a",
1886
+ session_id: "s1",
1887
+ });
1888
+ const line2 = JSON.stringify({
1889
+ type: "tool_progress",
1890
+ tool_use_id: "tu-b",
1891
+ tool_name: "Edit",
1892
+ parent_tool_use_id: null,
1893
+ elapsed_time_seconds: 2,
1894
+ uuid: "uuid-b",
1895
+ session_id: "s1",
1896
+ });
1897
+
1898
+ await bridge.handleCLIMessage(cli, line1 + "\n" + line2);
1899
+
1900
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1901
+ const progressMsgs = calls.filter((c: any) => c.type === "tool_progress");
1902
+ expect(progressMsgs).toHaveLength(2);
1903
+ expect(progressMsgs[0].tool_use_id).toBe("tu-a");
1904
+ expect(progressMsgs[1].tool_use_id).toBe("tu-b");
1905
+ });
1906
+
1907
+ it("malformed JSON: skips gracefully without crashing", async () => {
1908
+ const validLine = JSON.stringify({ type: "keep_alive" });
1909
+ const raw = "not-valid-json\n" + validLine;
1910
+
1911
+ // Should not throw (async — just await it directly)
1912
+ await bridge.handleCLIMessage(cli, raw);
1913
+ // Parse errors now surface as error messages to the browser,
1914
+ // but keep_alive is still silently consumed. Only the parse error
1915
+ // should reach the browser.
1916
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
1917
+ const errorMsgs = calls.filter((c: any) => c.type === "error");
1918
+ expect(errorMsgs.length).toBe(1);
1919
+ expect(errorMsgs[0].message).toContain("parse_error");
1920
+ // No keep_alive should have been broadcast
1921
+ expect(calls.filter((c: any) => c.type === "keep_alive").length).toBe(0);
1922
+ });
1923
+ });
1924
+
1925
+ // ─── Browser message routing ─────────────────────────────────────────────────
1926
+
1927
+ describe("Browser message routing", () => {
1928
+ let cli: ReturnType<typeof makeCliSocket>;
1929
+ let browser: ReturnType<typeof makeBrowserSocket>;
1930
+
1931
+ beforeEach(() => {
1932
+ cli = makeCliSocket("s1");
1933
+ browser = makeBrowserSocket("s1");
1934
+ bridge.handleCLIOpen(cli, "s1");
1935
+ bridge.handleBrowserOpen(browser, "s1");
1936
+ cli.send.mockClear();
1937
+ browser.send.mockClear();
1938
+ });
1939
+
1940
+ it("user_message: sends NDJSON to CLI and stores in history", () => {
1941
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1942
+ type: "user_message",
1943
+ content: "What is 2+2?",
1944
+ }));
1945
+
1946
+ // Should have sent NDJSON to CLI
1947
+ expect(cli.send).toHaveBeenCalledTimes(1);
1948
+ const sentRaw = cli.send.mock.calls[0][0] as string;
1949
+ const sent = JSON.parse(sentRaw.trim());
1950
+ expect(sent.type).toBe("user");
1951
+ expect(sent.message.role).toBe("user");
1952
+ expect(sent.message.content).toBe("What is 2+2?");
1953
+
1954
+ // Should store in history
1955
+ const session = bridge.getSession("s1")!;
1956
+ expect(session.messageHistory).toHaveLength(1);
1957
+ expect(session.messageHistory[0].type).toBe("user_message");
1958
+ if (session.messageHistory[0].type === "user_message") {
1959
+ expect(session.messageHistory[0].content).toBe("What is 2+2?");
1960
+ }
1961
+ });
1962
+
1963
+ it("user_message: queues when CLI not connected", () => {
1964
+ // Close CLI
1965
+ bridge.handleCLIClose(cli);
1966
+ browser.send.mockClear();
1967
+
1968
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1969
+ type: "user_message",
1970
+ content: "queued message",
1971
+ }));
1972
+
1973
+ // Messages are now queued as BrowserOutgoingMessage JSON (not NDJSON)
1974
+ // and converted to backend format when flushed via adapter.send()
1975
+ const session = bridge.getSession("s1")!;
1976
+ expect(session.pendingMessages).toHaveLength(1);
1977
+ const queued = JSON.parse(session.pendingMessages[0]);
1978
+ expect(queued.type).toBe("user_message");
1979
+ expect(queued.content).toBe("queued message");
1980
+ });
1981
+
1982
+ it("user_message: re-queues when backend send fails despite adapter connected", () => {
1983
+ const session = bridge.getSession("s1")!;
1984
+ session.backendAdapter = {
1985
+ isConnected: () => true,
1986
+ send: () => false,
1987
+ disconnect: async () => {},
1988
+ onBrowserMessage: () => {},
1989
+ onSessionMeta: () => {},
1990
+ onDisconnect: () => {},
1991
+ } as any;
1992
+
1993
+ bridge.handleBrowserMessage(browser, JSON.stringify({
1994
+ type: "user_message",
1995
+ content: "retry this",
1996
+ }));
1997
+
1998
+ expect(session.pendingMessages).toHaveLength(1);
1999
+ const queued = JSON.parse(session.pendingMessages[0]);
2000
+ expect(queued.type).toBe("user_message");
2001
+ expect(queued.content).toBe("retry this");
2002
+ });
2003
+
2004
+ it("flushes bridge-queued messages once backend becomes connected", () => {
2005
+ const browser = makeBrowserSocket("codex-s1");
2006
+ bridge.handleBrowserOpen(browser, "codex-s1");
2007
+
2008
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2009
+ type: "user_message",
2010
+ content: "hello queued before connect",
2011
+ }));
2012
+
2013
+ const session = bridge.getSession("codex-s1")!;
2014
+ expect(session.pendingMessages).toHaveLength(1);
2015
+
2016
+ let connected = false;
2017
+ const send = vi.fn((msg: any) => connected);
2018
+ const adapter = {
2019
+ isConnected: () => connected,
2020
+ send,
2021
+ disconnect: async () => {},
2022
+ onBrowserMessage: () => {},
2023
+ onSessionMeta: () => {},
2024
+ onDisconnect: () => {},
2025
+ onInitError: () => {},
2026
+ };
2027
+
2028
+ bridge.attachBackendAdapter("codex-s1", adapter as any, "codex");
2029
+
2030
+ // Initial attach flush is attempted but backend still disconnected,
2031
+ // so the queued message must remain pending.
2032
+ expect(send).toHaveBeenCalledTimes(1);
2033
+ expect(session.pendingMessages).toHaveLength(1);
2034
+
2035
+ connected = true;
2036
+ bridge.handleBrowserMessage(browser, JSON.stringify({ type: "mcp_get_status" }));
2037
+
2038
+ // Queued user message flushes first, then current message is dispatched.
2039
+ expect(session.pendingMessages).toHaveLength(0);
2040
+ expect(send).toHaveBeenCalledTimes(3);
2041
+ const messageTypes = send.mock.calls.map(([msg]: [any]) => msg.type);
2042
+ expect(messageTypes).toEqual(["user_message", "user_message", "mcp_get_status"]);
2043
+ });
2044
+
2045
+ it("flushes bridge-queued messages when codex session init marks the adapter connected", () => {
2046
+ const browser = makeBrowserSocket("codex-init-flush");
2047
+ bridge.handleBrowserOpen(browser, "codex-init-flush");
2048
+
2049
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2050
+ type: "user_message",
2051
+ content: "flush me after codex init",
2052
+ }));
2053
+
2054
+ const session = bridge.getSession("codex-init-flush")!;
2055
+ expect(session.pendingMessages).toHaveLength(1);
2056
+
2057
+ let onBrowserMessage: ((msg: any) => void) | undefined;
2058
+ let onSessionMeta: ((meta: any) => void) | undefined;
2059
+ const send = vi.fn(() => connected);
2060
+ let connected = false;
2061
+ const adapter = {
2062
+ isConnected: () => connected,
2063
+ send,
2064
+ disconnect: async () => {},
2065
+ onBrowserMessage: (cb: (msg: any) => void) => {
2066
+ onBrowserMessage = cb;
2067
+ },
2068
+ onSessionMeta: (cb: (meta: any) => void) => {
2069
+ onSessionMeta = cb;
2070
+ },
2071
+ onDisconnect: () => {},
2072
+ onInitError: () => {},
2073
+ };
2074
+
2075
+ bridge.attachBackendAdapter("codex-init-flush", adapter as any, "codex");
2076
+
2077
+ expect(send).toHaveBeenCalledTimes(1);
2078
+ expect(session.pendingMessages).toHaveLength(1);
2079
+
2080
+ connected = true;
2081
+ onSessionMeta?.({
2082
+ cliSessionId: "thr-codex-init-flush",
2083
+ model: "gpt-5.4",
2084
+ cwd: "/test",
2085
+ });
2086
+ onBrowserMessage?.({
2087
+ type: "session_init",
2088
+ session: {
2089
+ session_id: "codex-init-flush",
2090
+ backend_type: "codex",
2091
+ model: "gpt-5.4",
2092
+ cwd: "/test",
2093
+ tools: [],
2094
+ permissionMode: "bypassPermissions",
2095
+ claude_code_version: "",
2096
+ mcp_servers: [],
2097
+ agents: [],
2098
+ slash_commands: [],
2099
+ skills: [],
2100
+ total_cost_usd: 0,
2101
+ num_turns: 0,
2102
+ context_used_percent: 0,
2103
+ is_compacting: false,
2104
+ git_branch: "",
2105
+ is_worktree: false,
2106
+ is_containerized: false,
2107
+ repo_root: "",
2108
+ git_ahead: 0,
2109
+ git_behind: 0,
2110
+ total_lines_added: 0,
2111
+ total_lines_removed: 0,
2112
+ },
2113
+ });
2114
+
2115
+ expect(send).toHaveBeenCalledTimes(2);
2116
+ const flushedCall = (send.mock.calls as any[][])[1];
2117
+ const flushedArg = flushedCall?.[0];
2118
+ expect(flushedCall).toBeDefined();
2119
+ expect(flushedArg).toMatchObject({
2120
+ type: "user_message",
2121
+ content: "flush me after codex init",
2122
+ });
2123
+ expect(session.pendingMessages).toHaveLength(0);
2124
+ });
2125
+
2126
+ it("preserves FIFO when queued flush is interrupted before sending current message", () => {
2127
+ const session = bridge.getSession("s1")!;
2128
+ session.pendingMessages.push(JSON.stringify({
2129
+ type: "user_message",
2130
+ content: "older queued",
2131
+ }));
2132
+
2133
+ const send = vi.fn((msg: any) => {
2134
+ if (msg.type === "user_message" && msg.content === "older queued" && send.mock.calls.length === 1) {
2135
+ return false;
2136
+ }
2137
+ return true;
2138
+ });
2139
+
2140
+ session.backendAdapter = {
2141
+ isConnected: () => true,
2142
+ send,
2143
+ disconnect: async () => {},
2144
+ onBrowserMessage: () => {},
2145
+ onSessionMeta: () => {},
2146
+ onDisconnect: () => {},
2147
+ } as any;
2148
+
2149
+ // First dispatch tries to flush the older queued message, fails, and must
2150
+ // queue the current message instead of sending it out-of-order.
2151
+ bridge.handleBrowserMessage(browser, JSON.stringify({ type: "mcp_get_status" }));
2152
+
2153
+ expect(send).toHaveBeenCalledTimes(1);
2154
+ expect(send.mock.calls[0][0]).toMatchObject({ type: "user_message", content: "older queued" });
2155
+ expect(session.pendingMessages).toHaveLength(2);
2156
+ expect(JSON.parse(session.pendingMessages[0])).toMatchObject({ type: "user_message", content: "older queued" });
2157
+ expect(JSON.parse(session.pendingMessages[1])).toMatchObject({ type: "mcp_get_status" });
2158
+ });
2159
+
2160
+ it("permission_response: does not re-queue when backend send fails", async () => {
2161
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2162
+ type: "control_request",
2163
+ request_id: "req-no-requeue",
2164
+ request: {
2165
+ subtype: "can_use_tool",
2166
+ tool_name: "Bash",
2167
+ input: { command: "echo hi" },
2168
+ tool_use_id: "tu-no-requeue",
2169
+ },
2170
+ }));
2171
+
2172
+ const session = bridge.getSession("s1")!;
2173
+ const send = vi.fn(() => false);
2174
+ session.backendAdapter = {
2175
+ isConnected: () => true,
2176
+ send,
2177
+ disconnect: async () => {},
2178
+ onBrowserMessage: () => {},
2179
+ onSessionMeta: () => {},
2180
+ onDisconnect: () => {},
2181
+ } as any;
2182
+
2183
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2184
+ type: "permission_response",
2185
+ request_id: "req-no-requeue",
2186
+ behavior: "allow",
2187
+ }));
2188
+
2189
+ expect(send).toHaveBeenCalledTimes(1);
2190
+ expect(session.pendingPermissions.has("req-no-requeue")).toBe(false);
2191
+ expect(session.pendingMessages).toHaveLength(0);
2192
+ });
2193
+
2194
+ it("user_message: deduplicates repeated client_msg_id", () => {
2195
+ const payload = {
2196
+ type: "user_message",
2197
+ content: "once only",
2198
+ client_msg_id: "client-msg-1",
2199
+ };
2200
+
2201
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2202
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2203
+
2204
+ expect(cli.send).toHaveBeenCalledTimes(1);
2205
+ const session = bridge.getSession("s1")!;
2206
+ const userMessages = session.messageHistory.filter((m) => m.type === "user_message");
2207
+ expect(userMessages).toHaveLength(1);
2208
+ });
2209
+
2210
+ it("user_message with images: builds content blocks", () => {
2211
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2212
+ type: "user_message",
2213
+ content: "What's in this image?",
2214
+ images: [
2215
+ { media_type: "image/png", data: "base64data==" },
2216
+ ],
2217
+ }));
2218
+
2219
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2220
+ const sent = JSON.parse(sentRaw.trim());
2221
+ expect(sent.type).toBe("user");
2222
+ expect(Array.isArray(sent.message.content)).toBe(true);
2223
+ expect(sent.message.content).toHaveLength(2);
2224
+ // First block should be the image
2225
+ expect(sent.message.content[0].type).toBe("image");
2226
+ expect(sent.message.content[0].source.type).toBe("base64");
2227
+ expect(sent.message.content[0].source.media_type).toBe("image/png");
2228
+ expect(sent.message.content[0].source.data).toBe("base64data==");
2229
+ // Second block should be the text
2230
+ expect(sent.message.content[1].type).toBe("text");
2231
+ expect(sent.message.content[1].text).toBe("What's in this image?");
2232
+ });
2233
+
2234
+ it("permission_response allow: sends control_response to CLI", async () => {
2235
+ // First create a pending permission
2236
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2237
+ type: "control_request",
2238
+ request_id: "req-allow",
2239
+ request: {
2240
+ subtype: "can_use_tool",
2241
+ tool_name: "Bash",
2242
+ input: { command: "echo hi" },
2243
+ tool_use_id: "tu-allow",
2244
+ },
2245
+ }));
2246
+ cli.send.mockClear();
2247
+
2248
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2249
+ type: "permission_response",
2250
+ request_id: "req-allow",
2251
+ behavior: "allow",
2252
+ }));
2253
+
2254
+ expect(cli.send).toHaveBeenCalledTimes(1);
2255
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2256
+ const sent = JSON.parse(sentRaw.trim());
2257
+ expect(sent.type).toBe("control_response");
2258
+ expect(sent.response.subtype).toBe("success");
2259
+ expect(sent.response.request_id).toBe("req-allow");
2260
+ expect(sent.response.response.behavior).toBe("allow");
2261
+ expect(sent.response.response.updatedInput).toEqual({ command: "echo hi" });
2262
+
2263
+ // Should remove from pending
2264
+ const session = bridge.getSession("s1")!;
2265
+ expect(session.pendingPermissions.has("req-allow")).toBe(false);
2266
+ });
2267
+
2268
+ it("permission_response deny: sends deny response to CLI", async () => {
2269
+ // Create a pending permission
2270
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2271
+ type: "control_request",
2272
+ request_id: "req-deny",
2273
+ request: {
2274
+ subtype: "can_use_tool",
2275
+ tool_name: "Bash",
2276
+ input: { command: "rm -rf /" },
2277
+ tool_use_id: "tu-deny",
2278
+ },
2279
+ }));
2280
+ cli.send.mockClear();
2281
+
2282
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2283
+ type: "permission_response",
2284
+ request_id: "req-deny",
2285
+ behavior: "deny",
2286
+ message: "Too dangerous",
2287
+ }));
2288
+
2289
+ expect(cli.send).toHaveBeenCalledTimes(1);
2290
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2291
+ const sent = JSON.parse(sentRaw.trim());
2292
+ expect(sent.type).toBe("control_response");
2293
+ expect(sent.response.subtype).toBe("success");
2294
+ expect(sent.response.request_id).toBe("req-deny");
2295
+ expect(sent.response.response.behavior).toBe("deny");
2296
+ expect(sent.response.response.message).toBe("Too dangerous");
2297
+
2298
+ // Should remove from pending
2299
+ const session = bridge.getSession("s1")!;
2300
+ expect(session.pendingPermissions.has("req-deny")).toBe(false);
2301
+ });
2302
+
2303
+ it("permission_response: deduplicates repeated client_msg_id", async () => {
2304
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2305
+ type: "control_request",
2306
+ request_id: "req-dedupe",
2307
+ request: {
2308
+ subtype: "can_use_tool",
2309
+ tool_name: "Bash",
2310
+ input: { command: "echo hi" },
2311
+ tool_use_id: "tu-dedupe",
2312
+ },
2313
+ }));
2314
+ cli.send.mockClear();
2315
+
2316
+ const payload = {
2317
+ type: "permission_response",
2318
+ request_id: "req-dedupe",
2319
+ behavior: "allow",
2320
+ client_msg_id: "perm-msg-1",
2321
+ };
2322
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2323
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2324
+
2325
+ expect(cli.send).toHaveBeenCalledTimes(1);
2326
+ const session = bridge.getSession("s1")!;
2327
+ expect(session.pendingPermissions.has("req-dedupe")).toBe(false);
2328
+ });
2329
+
2330
+ it("interrupt: sends control_request with interrupt subtype to CLI", () => {
2331
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2332
+ type: "interrupt",
2333
+ }));
2334
+
2335
+ expect(cli.send).toHaveBeenCalledTimes(1);
2336
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2337
+ const sent = JSON.parse(sentRaw.trim());
2338
+ expect(sent.type).toBe("control_request");
2339
+ expect(sent.request_id).toBe("test-uuid");
2340
+ expect(sent.request.subtype).toBe("interrupt");
2341
+ });
2342
+
2343
+ it("interrupt: deduplicates repeated client_msg_id", () => {
2344
+ const payload = { type: "interrupt", client_msg_id: "ctrl-msg-1" };
2345
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2346
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2347
+
2348
+ expect(cli.send).toHaveBeenCalledTimes(1);
2349
+ });
2350
+
2351
+ it("set_model: sends control_request with set_model subtype to CLI", () => {
2352
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2353
+ type: "set_model",
2354
+ model: "claude-opus-4-5-20250929",
2355
+ }));
2356
+
2357
+ expect(cli.send).toHaveBeenCalledTimes(1);
2358
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2359
+ const sent = JSON.parse(sentRaw.trim());
2360
+ expect(sent.type).toBe("control_request");
2361
+ expect(sent.request_id).toBe("test-uuid");
2362
+ expect(sent.request.subtype).toBe("set_model");
2363
+ expect(sent.request.model).toBe("claude-opus-4-5-20250929");
2364
+ });
2365
+
2366
+ it("set_permission_mode: sends control_request with set_permission_mode subtype to CLI", () => {
2367
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2368
+ type: "set_permission_mode",
2369
+ mode: "bypassPermissions",
2370
+ }));
2371
+
2372
+ expect(cli.send).toHaveBeenCalledTimes(1);
2373
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2374
+ const sent = JSON.parse(sentRaw.trim());
2375
+ expect(sent.type).toBe("control_request");
2376
+ expect(sent.request_id).toBe("test-uuid");
2377
+ expect(sent.request.subtype).toBe("set_permission_mode");
2378
+ expect(sent.request.mode).toBe("bypassPermissions");
2379
+ });
2380
+
2381
+ it("set_model: deduplicates repeated client_msg_id", () => {
2382
+ const payload = {
2383
+ type: "set_model",
2384
+ model: "claude-opus-4-5-20250929",
2385
+ client_msg_id: "set-model-1",
2386
+ };
2387
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2388
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2389
+ expect(cli.send).toHaveBeenCalledTimes(1);
2390
+ });
2391
+
2392
+ it("set_permission_mode: deduplicates repeated client_msg_id", () => {
2393
+ const payload = {
2394
+ type: "set_permission_mode",
2395
+ mode: "plan",
2396
+ client_msg_id: "set-mode-1",
2397
+ };
2398
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2399
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2400
+ expect(cli.send).toHaveBeenCalledTimes(1);
2401
+ });
2402
+
2403
+ it("mcp_toggle: deduplicates repeated client_msg_id", () => {
2404
+ const payload = {
2405
+ type: "mcp_toggle",
2406
+ serverName: "my-mcp",
2407
+ enabled: true,
2408
+ client_msg_id: "mcp-msg-1",
2409
+ };
2410
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2411
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2412
+
2413
+ // 1 send for mcp_toggle control_request + delayed status refresh timer not run in this assertion window.
2414
+ expect(cli.send).toHaveBeenCalledTimes(1);
2415
+ });
2416
+
2417
+ it("mcp_get_status: deduplicates repeated client_msg_id", () => {
2418
+ const payload = {
2419
+ type: "mcp_get_status",
2420
+ client_msg_id: "mcp-status-1",
2421
+ };
2422
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2423
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2424
+ expect(cli.send).toHaveBeenCalledTimes(1);
2425
+ });
2426
+
2427
+ it("mcp_reconnect: deduplicates repeated client_msg_id", () => {
2428
+ const payload = {
2429
+ type: "mcp_reconnect",
2430
+ serverName: "my-mcp",
2431
+ client_msg_id: "mcp-reconnect-1",
2432
+ };
2433
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2434
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2435
+ expect(cli.send).toHaveBeenCalledTimes(1);
2436
+ });
2437
+
2438
+ it("mcp_set_servers: deduplicates repeated client_msg_id", () => {
2439
+ const payload = {
2440
+ type: "mcp_set_servers",
2441
+ servers: {
2442
+ "server-a": {
2443
+ type: "stdio",
2444
+ command: "node",
2445
+ args: ["server.js"],
2446
+ },
2447
+ },
2448
+ client_msg_id: "mcp-set-servers-1",
2449
+ };
2450
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2451
+ bridge.handleBrowserMessage(browser, JSON.stringify(payload));
2452
+ expect(cli.send).toHaveBeenCalledTimes(1);
2453
+ });
2454
+ });
2455
+
2456
+ // ─── Persistence ─────────────────────────────────────────────────────────────
2457
+
2458
+ describe("Persistence", () => {
2459
+ it("restoreFromDisk: loads sessions from store", () => {
2460
+ // Save a session directly to the store
2461
+ store.saveSync({
2462
+ id: "persisted-1",
2463
+ state: {
2464
+ session_id: "persisted-1",
2465
+ model: "claude-sonnet-4-6",
2466
+ cwd: "/saved",
2467
+ tools: ["Bash"],
2468
+ permissionMode: "default",
2469
+ claude_code_version: "1.0",
2470
+ mcp_servers: [],
2471
+ agents: [],
2472
+ slash_commands: [],
2473
+ skills: [],
2474
+ total_cost_usd: 0.1,
2475
+ num_turns: 5,
2476
+ context_used_percent: 15,
2477
+ is_compacting: false,
2478
+ git_branch: "main",
2479
+ is_worktree: false,
2480
+ is_containerized: false,
2481
+ repo_root: "/saved",
2482
+ git_ahead: 0,
2483
+ git_behind: 0,
2484
+ total_lines_added: 0,
2485
+ total_lines_removed: 0,
2486
+ },
2487
+ messageHistory: [
2488
+ { type: "user_message", content: "Hello", timestamp: 1000 },
2489
+ ],
2490
+ pendingMessages: [],
2491
+ pendingPermissions: [],
2492
+ processedClientMessageIds: ["restored-client-1"],
2493
+ });
2494
+
2495
+ const count = bridge.restoreFromDisk();
2496
+ expect(count).toBe(1);
2497
+
2498
+ const session = bridge.getSession("persisted-1");
2499
+ expect(session).toBeDefined();
2500
+ expect(session!.state.model).toBe("claude-sonnet-4-6");
2501
+ expect(session!.state.cwd).toBe("/saved");
2502
+ expect(session!.state.total_cost_usd).toBe(0.1);
2503
+ expect(session!.messageHistory).toHaveLength(1);
2504
+ expect(session!.backendAdapter).toBeNull();
2505
+ expect(session!.browserSockets.size).toBe(0);
2506
+ expect(session!.processedClientMessageIdSet.has("restored-client-1")).toBe(true);
2507
+ });
2508
+
2509
+ it("restoreFromDisk: does not overwrite live sessions", () => {
2510
+ // Create a live session first
2511
+ const liveSession = bridge.getOrCreateSession("live-1");
2512
+ liveSession.state.model = "live-model";
2513
+
2514
+ // Save a different version to disk
2515
+ store.saveSync({
2516
+ id: "live-1",
2517
+ state: {
2518
+ session_id: "live-1",
2519
+ model: "disk-model",
2520
+ cwd: "/disk",
2521
+ tools: [],
2522
+ permissionMode: "default",
2523
+ claude_code_version: "1.0",
2524
+ mcp_servers: [],
2525
+ agents: [],
2526
+ slash_commands: [],
2527
+ skills: [],
2528
+ total_cost_usd: 0,
2529
+ num_turns: 0,
2530
+ context_used_percent: 0,
2531
+ is_compacting: false,
2532
+ git_branch: "",
2533
+ is_worktree: false,
2534
+ is_containerized: false,
2535
+ repo_root: "",
2536
+ git_ahead: 0,
2537
+ git_behind: 0,
2538
+ total_lines_added: 0,
2539
+ total_lines_removed: 0,
2540
+ },
2541
+ messageHistory: [],
2542
+ pendingMessages: [],
2543
+ pendingPermissions: [],
2544
+ });
2545
+
2546
+ const count = bridge.restoreFromDisk();
2547
+ expect(count).toBe(0);
2548
+
2549
+ // Should still have the live model
2550
+ const session = bridge.getSession("live-1")!;
2551
+ expect(session.state.model).toBe("live-model");
2552
+ });
2553
+
2554
+ it("persistSession: called after state changes (via store.save)", async () => {
2555
+ mockExecSync.mockImplementation(() => {
2556
+ throw new Error("not a git repo");
2557
+ });
2558
+
2559
+ const saveSpy = vi.spyOn(store, "save");
2560
+
2561
+ const cli = makeCliSocket("s1");
2562
+ bridge.handleCLIOpen(cli, "s1");
2563
+
2564
+ // system.init should trigger persist
2565
+ await bridge.handleCLIMessage(cli, makeInitMsg());
2566
+ expect(saveSpy).toHaveBeenCalled();
2567
+
2568
+ const lastCall = saveSpy.mock.calls[saveSpy.mock.calls.length - 1][0];
2569
+ expect(lastCall.id).toBe("s1");
2570
+ expect(lastCall.state.model).toBe("claude-sonnet-4-6");
2571
+
2572
+ saveSpy.mockClear();
2573
+
2574
+ // assistant message should trigger persist
2575
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2576
+ type: "assistant",
2577
+ message: {
2578
+ id: "msg-1",
2579
+ type: "message",
2580
+ role: "assistant",
2581
+ model: "claude-sonnet-4-6",
2582
+ content: [{ type: "text", text: "Test" }],
2583
+ stop_reason: "end_turn",
2584
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
2585
+ },
2586
+ parent_tool_use_id: null,
2587
+ uuid: "uuid-p1",
2588
+ session_id: "s1",
2589
+ }));
2590
+ expect(saveSpy).toHaveBeenCalled();
2591
+
2592
+ saveSpy.mockClear();
2593
+
2594
+ // result message should trigger persist
2595
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2596
+ type: "result",
2597
+ subtype: "success",
2598
+ is_error: false,
2599
+ duration_ms: 1000,
2600
+ duration_api_ms: 800,
2601
+ num_turns: 1,
2602
+ total_cost_usd: 0.01,
2603
+ stop_reason: "end_turn",
2604
+ usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
2605
+ uuid: "uuid-p2",
2606
+ session_id: "s1",
2607
+ }));
2608
+ expect(saveSpy).toHaveBeenCalled();
2609
+
2610
+ saveSpy.mockClear();
2611
+
2612
+ // control_request (can_use_tool) should trigger persist
2613
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2614
+ type: "control_request",
2615
+ request_id: "req-persist",
2616
+ request: {
2617
+ subtype: "can_use_tool",
2618
+ tool_name: "Bash",
2619
+ input: { command: "echo test" },
2620
+ tool_use_id: "tu-persist",
2621
+ },
2622
+ }));
2623
+ expect(saveSpy).toHaveBeenCalled();
2624
+
2625
+ saveSpy.mockClear();
2626
+
2627
+ // user message from browser should trigger persist
2628
+ const browserWs = makeBrowserSocket("s1");
2629
+ bridge.handleBrowserOpen(browserWs, "s1");
2630
+ bridge.handleBrowserMessage(browserWs, JSON.stringify({
2631
+ type: "user_message",
2632
+ content: "test persist",
2633
+ }));
2634
+ expect(saveSpy).toHaveBeenCalled();
2635
+ });
2636
+ });
2637
+
2638
+ // ─── auth_status message routing ──────────────────────────────────────────────
2639
+
2640
+ describe("auth_status message routing", () => {
2641
+ let cli: ReturnType<typeof makeCliSocket>;
2642
+ let browser: ReturnType<typeof makeBrowserSocket>;
2643
+
2644
+ beforeEach(() => {
2645
+ cli = makeCliSocket("s1");
2646
+ browser = makeBrowserSocket("s1");
2647
+ bridge.handleCLIOpen(cli, "s1");
2648
+ bridge.handleBrowserOpen(browser, "s1");
2649
+ browser.send.mockClear();
2650
+ });
2651
+
2652
+ it("broadcasts auth_status with isAuthenticating: true", async () => {
2653
+ const msg = JSON.stringify({
2654
+ type: "auth_status",
2655
+ isAuthenticating: true,
2656
+ output: ["Waiting for authentication..."],
2657
+ uuid: "uuid-auth-1",
2658
+ session_id: "s1",
2659
+ });
2660
+
2661
+ await bridge.handleCLIMessage(cli, msg);
2662
+
2663
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
2664
+ const authMsg = calls.find((c: any) => c.type === "auth_status");
2665
+ expect(authMsg).toBeDefined();
2666
+ expect(authMsg.isAuthenticating).toBe(true);
2667
+ expect(authMsg.output).toEqual(["Waiting for authentication..."]);
2668
+ expect(authMsg.error).toBeUndefined();
2669
+ });
2670
+
2671
+ it("broadcasts auth_status with isAuthenticating: false", async () => {
2672
+ const msg = JSON.stringify({
2673
+ type: "auth_status",
2674
+ isAuthenticating: false,
2675
+ output: ["Authentication complete"],
2676
+ uuid: "uuid-auth-2",
2677
+ session_id: "s1",
2678
+ });
2679
+
2680
+ await bridge.handleCLIMessage(cli, msg);
2681
+
2682
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
2683
+ const authMsg = calls.find((c: any) => c.type === "auth_status");
2684
+ expect(authMsg).toBeDefined();
2685
+ expect(authMsg.isAuthenticating).toBe(false);
2686
+ expect(authMsg.output).toEqual(["Authentication complete"]);
2687
+ });
2688
+
2689
+ it("broadcasts auth_status with error field", async () => {
2690
+ const msg = JSON.stringify({
2691
+ type: "auth_status",
2692
+ isAuthenticating: false,
2693
+ output: ["Failed to authenticate"],
2694
+ error: "Token expired",
2695
+ uuid: "uuid-auth-3",
2696
+ session_id: "s1",
2697
+ });
2698
+
2699
+ await bridge.handleCLIMessage(cli, msg);
2700
+
2701
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
2702
+ const authMsg = calls.find((c: any) => c.type === "auth_status");
2703
+ expect(authMsg).toBeDefined();
2704
+ expect(authMsg.isAuthenticating).toBe(false);
2705
+ expect(authMsg.error).toBe("Token expired");
2706
+ expect(authMsg.output).toEqual(["Failed to authenticate"]);
2707
+ });
2708
+ });
2709
+
2710
+ // ─── permission_response with updated_permissions ─────────────────────────────
2711
+
2712
+ describe("permission_response with updated_permissions", () => {
2713
+ let cli: ReturnType<typeof makeCliSocket>;
2714
+ let browser: ReturnType<typeof makeBrowserSocket>;
2715
+
2716
+ beforeEach(() => {
2717
+ cli = makeCliSocket("s1");
2718
+ browser = makeBrowserSocket("s1");
2719
+ bridge.handleCLIOpen(cli, "s1");
2720
+ bridge.handleBrowserOpen(browser, "s1");
2721
+ cli.send.mockClear();
2722
+ browser.send.mockClear();
2723
+ });
2724
+
2725
+ it("allow with updated_permissions forwards updatedPermissions in control_response", async () => {
2726
+ // Create pending permission
2727
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2728
+ type: "control_request",
2729
+ request_id: "req-perm-update",
2730
+ request: {
2731
+ subtype: "can_use_tool",
2732
+ tool_name: "Bash",
2733
+ input: { command: "echo hello" },
2734
+ tool_use_id: "tu-perm-update",
2735
+ },
2736
+ }));
2737
+ cli.send.mockClear();
2738
+
2739
+ const updatedPermissions = [
2740
+ { type: "addRules", rules: [{ toolName: "Bash", ruleContent: "echo *" }], behavior: "allow", destination: "session" },
2741
+ ];
2742
+
2743
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2744
+ type: "permission_response",
2745
+ request_id: "req-perm-update",
2746
+ behavior: "allow",
2747
+ updated_permissions: updatedPermissions,
2748
+ }));
2749
+
2750
+ expect(cli.send).toHaveBeenCalledTimes(1);
2751
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2752
+ const sent = JSON.parse(sentRaw.trim());
2753
+ expect(sent.type).toBe("control_response");
2754
+ expect(sent.response.response.behavior).toBe("allow");
2755
+ expect(sent.response.response.updatedPermissions).toEqual(updatedPermissions);
2756
+ });
2757
+
2758
+ it("allow without updated_permissions does not include updatedPermissions key", async () => {
2759
+ // Create pending permission
2760
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2761
+ type: "control_request",
2762
+ request_id: "req-no-perm",
2763
+ request: {
2764
+ subtype: "can_use_tool",
2765
+ tool_name: "Read",
2766
+ input: { file_path: "/test.ts" },
2767
+ tool_use_id: "tu-no-perm",
2768
+ },
2769
+ }));
2770
+ cli.send.mockClear();
2771
+
2772
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2773
+ type: "permission_response",
2774
+ request_id: "req-no-perm",
2775
+ behavior: "allow",
2776
+ }));
2777
+
2778
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2779
+ const sent = JSON.parse(sentRaw.trim());
2780
+ expect(sent.response.response.updatedPermissions).toBeUndefined();
2781
+ });
2782
+
2783
+ it("allow with empty updated_permissions does not include updatedPermissions key", async () => {
2784
+ await bridge.handleCLIMessage(cli, JSON.stringify({
2785
+ type: "control_request",
2786
+ request_id: "req-empty-perm",
2787
+ request: {
2788
+ subtype: "can_use_tool",
2789
+ tool_name: "Read",
2790
+ input: { file_path: "/test.ts" },
2791
+ tool_use_id: "tu-empty-perm",
2792
+ },
2793
+ }));
2794
+ cli.send.mockClear();
2795
+
2796
+ bridge.handleBrowserMessage(browser, JSON.stringify({
2797
+ type: "permission_response",
2798
+ request_id: "req-empty-perm",
2799
+ behavior: "allow",
2800
+ updated_permissions: [],
2801
+ }));
2802
+
2803
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2804
+ const sent = JSON.parse(sentRaw.trim());
2805
+ expect(sent.response.response.updatedPermissions).toBeUndefined();
2806
+ });
2807
+ });
2808
+
2809
+ // ─── Multiple browser sockets ─────────────────────────────────────────────────
2810
+
2811
+ describe("Multiple browser sockets", () => {
2812
+ it("broadcasts to ALL connected browsers", async () => {
2813
+ const cli = makeCliSocket("s1");
2814
+ const browser1 = makeBrowserSocket("s1");
2815
+ const browser2 = makeBrowserSocket("s1");
2816
+ const browser3 = makeBrowserSocket("s1");
2817
+
2818
+ bridge.handleCLIOpen(cli, "s1");
2819
+ bridge.handleBrowserOpen(browser1, "s1");
2820
+ bridge.handleBrowserOpen(browser2, "s1");
2821
+ bridge.handleBrowserOpen(browser3, "s1");
2822
+ browser1.send.mockClear();
2823
+ browser2.send.mockClear();
2824
+ browser3.send.mockClear();
2825
+
2826
+ const msg = JSON.stringify({
2827
+ type: "tool_progress",
2828
+ tool_use_id: "tu-multi",
2829
+ tool_name: "Bash",
2830
+ parent_tool_use_id: null,
2831
+ elapsed_time_seconds: 1.5,
2832
+ uuid: "uuid-multi",
2833
+ session_id: "s1",
2834
+ });
2835
+ await bridge.handleCLIMessage(cli, msg);
2836
+
2837
+ // All three browsers should receive the broadcast
2838
+ for (const browser of [browser1, browser2, browser3]) {
2839
+ expect(browser.send).toHaveBeenCalledTimes(1);
2840
+ const sent = JSON.parse(browser.send.mock.calls[0][0]);
2841
+ expect(sent.type).toBe("tool_progress");
2842
+ expect(sent.tool_use_id).toBe("tu-multi");
2843
+ }
2844
+ });
2845
+
2846
+ it("removes a browser whose send() throws, but others continue to receive", async () => {
2847
+ const cli = makeCliSocket("s1");
2848
+ const browser1 = makeBrowserSocket("s1");
2849
+ const browser2 = makeBrowserSocket("s1");
2850
+ const browser3 = makeBrowserSocket("s1");
2851
+
2852
+ bridge.handleCLIOpen(cli, "s1");
2853
+ bridge.handleBrowserOpen(browser1, "s1");
2854
+ bridge.handleBrowserOpen(browser2, "s1");
2855
+ bridge.handleBrowserOpen(browser3, "s1");
2856
+ browser1.send.mockClear();
2857
+ browser2.send.mockClear();
2858
+ browser3.send.mockClear();
2859
+
2860
+ // Make browser2's send throw
2861
+ browser2.send.mockImplementation(() => {
2862
+ throw new Error("WebSocket closed");
2863
+ });
2864
+
2865
+ const msg = JSON.stringify({
2866
+ type: "tool_progress",
2867
+ tool_use_id: "tu-fail",
2868
+ tool_name: "Bash",
2869
+ parent_tool_use_id: null,
2870
+ elapsed_time_seconds: 2,
2871
+ uuid: "uuid-fail",
2872
+ session_id: "s1",
2873
+ });
2874
+ await bridge.handleCLIMessage(cli, msg);
2875
+
2876
+ // browser1 and browser3 should have received the message
2877
+ expect(browser1.send).toHaveBeenCalledTimes(1);
2878
+ expect(browser3.send).toHaveBeenCalledTimes(1);
2879
+
2880
+ // browser2 should have been removed from the set
2881
+ const session = bridge.getSession("s1")!;
2882
+ expect(session.browserSockets.has(browser2)).toBe(false);
2883
+ expect(session.browserSockets.has(browser1)).toBe(true);
2884
+ expect(session.browserSockets.has(browser3)).toBe(true);
2885
+ expect(session.browserSockets.size).toBe(2);
2886
+ });
2887
+ });
2888
+
2889
+ // ─── handleCLIMessage with Buffer ─────────────────────────────────────────────
2890
+
2891
+ describe("handleCLIMessage with Buffer", () => {
2892
+ it("parses Buffer input correctly", async () => {
2893
+ const cli = makeCliSocket("s1");
2894
+ const browser = makeBrowserSocket("s1");
2895
+ bridge.handleCLIOpen(cli, "s1");
2896
+ bridge.handleBrowserOpen(browser, "s1");
2897
+ browser.send.mockClear();
2898
+
2899
+ const jsonStr = JSON.stringify({
2900
+ type: "tool_progress",
2901
+ tool_use_id: "tu-buf",
2902
+ tool_name: "Bash",
2903
+ parent_tool_use_id: null,
2904
+ elapsed_time_seconds: 1,
2905
+ uuid: "uuid-buf",
2906
+ session_id: "s1",
2907
+ });
2908
+
2909
+ // Pass as Buffer instead of string
2910
+ await bridge.handleCLIMessage(cli, Buffer.from(jsonStr, "utf-8"));
2911
+
2912
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
2913
+ const progressMsg = calls.find((c: any) => c.type === "tool_progress");
2914
+ expect(progressMsg).toBeDefined();
2915
+ expect(progressMsg.tool_use_id).toBe("tu-buf");
2916
+ expect(progressMsg.tool_name).toBe("Bash");
2917
+ });
2918
+
2919
+ it("handles multi-line NDJSON as Buffer", async () => {
2920
+ const cli = makeCliSocket("s1");
2921
+ const browser = makeBrowserSocket("s1");
2922
+ bridge.handleCLIOpen(cli, "s1");
2923
+ bridge.handleBrowserOpen(browser, "s1");
2924
+ browser.send.mockClear();
2925
+
2926
+ const line1 = JSON.stringify({ type: "keep_alive" });
2927
+ const line2 = JSON.stringify({
2928
+ type: "tool_progress",
2929
+ tool_use_id: "tu-buf2",
2930
+ tool_name: "Read",
2931
+ parent_tool_use_id: null,
2932
+ elapsed_time_seconds: 3,
2933
+ uuid: "uuid-buf2",
2934
+ session_id: "s1",
2935
+ });
2936
+ const ndjson = line1 + "\n" + line2;
2937
+
2938
+ await bridge.handleCLIMessage(cli, Buffer.from(ndjson, "utf-8"));
2939
+
2940
+ // keep_alive is silently consumed, only tool_progress should be broadcast
2941
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
2942
+ expect(calls).toHaveLength(1);
2943
+ expect(calls[0].type).toBe("tool_progress");
2944
+ expect(calls[0].tool_use_id).toBe("tu-buf2");
2945
+ });
2946
+ });
2947
+
2948
+ // ─── handleBrowserMessage with Buffer ─────────────────────────────────────────
2949
+
2950
+ describe("handleBrowserMessage with Buffer", () => {
2951
+ it("parses Buffer input and routes user_message correctly", () => {
2952
+ const cli = makeCliSocket("s1");
2953
+ const browser = makeBrowserSocket("s1");
2954
+ bridge.handleCLIOpen(cli, "s1");
2955
+ bridge.handleBrowserOpen(browser, "s1");
2956
+ cli.send.mockClear();
2957
+
2958
+ const msgStr = JSON.stringify({
2959
+ type: "user_message",
2960
+ content: "Hello from buffer",
2961
+ });
2962
+
2963
+ bridge.handleBrowserMessage(browser, Buffer.from(msgStr, "utf-8"));
2964
+
2965
+ expect(cli.send).toHaveBeenCalledTimes(1);
2966
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2967
+ const sent = JSON.parse(sentRaw.trim());
2968
+ expect(sent.type).toBe("user");
2969
+ expect(sent.message.content).toBe("Hello from buffer");
2970
+ });
2971
+
2972
+ it("parses Buffer input and routes interrupt correctly", () => {
2973
+ const cli = makeCliSocket("s1");
2974
+ const browser = makeBrowserSocket("s1");
2975
+ bridge.handleCLIOpen(cli, "s1");
2976
+ bridge.handleBrowserOpen(browser, "s1");
2977
+ cli.send.mockClear();
2978
+
2979
+ const msgStr = JSON.stringify({ type: "interrupt" });
2980
+ bridge.handleBrowserMessage(browser, Buffer.from(msgStr, "utf-8"));
2981
+
2982
+ expect(cli.send).toHaveBeenCalledTimes(1);
2983
+ const sentRaw = cli.send.mock.calls[0][0] as string;
2984
+ const sent = JSON.parse(sentRaw.trim());
2985
+ expect(sent.type).toBe("control_request");
2986
+ expect(sent.request.subtype).toBe("interrupt");
2987
+ });
2988
+ });
2989
+
2990
+ // ─── handleBrowserMessage with malformed JSON ─────────────────────────────────
2991
+
2992
+ describe("handleBrowserMessage with malformed JSON", () => {
2993
+ it("does not throw on invalid JSON", async () => {
2994
+ const cli = makeCliSocket("s1");
2995
+ const browser = makeBrowserSocket("s1");
2996
+ bridge.handleCLIOpen(cli, "s1");
2997
+ bridge.handleBrowserOpen(browser, "s1");
2998
+ cli.send.mockClear();
2999
+
3000
+ expect(() => {
3001
+ bridge.handleBrowserMessage(browser, "this is not json {{{");
3002
+ }).not.toThrow();
3003
+
3004
+ // CLI should not receive anything
3005
+ expect(cli.send).not.toHaveBeenCalled();
3006
+ });
3007
+
3008
+ it("does not throw on empty string", () => {
3009
+ const cli = makeCliSocket("s1");
3010
+ const browser = makeBrowserSocket("s1");
3011
+ bridge.handleCLIOpen(cli, "s1");
3012
+ bridge.handleBrowserOpen(browser, "s1");
3013
+ cli.send.mockClear();
3014
+
3015
+ expect(() => {
3016
+ bridge.handleBrowserMessage(browser, "");
3017
+ }).not.toThrow();
3018
+
3019
+ expect(cli.send).not.toHaveBeenCalled();
3020
+ });
3021
+
3022
+ it("does not throw on truncated JSON", () => {
3023
+ const cli = makeCliSocket("s1");
3024
+ const browser = makeBrowserSocket("s1");
3025
+ bridge.handleCLIOpen(cli, "s1");
3026
+ bridge.handleBrowserOpen(browser, "s1");
3027
+ cli.send.mockClear();
3028
+
3029
+ expect(() => {
3030
+ bridge.handleBrowserMessage(browser, '{"type":"user_message","con');
3031
+ }).not.toThrow();
3032
+
3033
+ expect(cli.send).not.toHaveBeenCalled();
3034
+ });
3035
+ });
3036
+
3037
+ // ─── Empty NDJSON lines ───────────────────────────────────────────────────────
3038
+
3039
+ describe("Empty NDJSON lines", () => {
3040
+ let cli: ReturnType<typeof makeCliSocket>;
3041
+ let browser: ReturnType<typeof makeBrowserSocket>;
3042
+
3043
+ beforeEach(() => {
3044
+ cli = makeCliSocket("s1");
3045
+ browser = makeBrowserSocket("s1");
3046
+ bridge.handleCLIOpen(cli, "s1");
3047
+ bridge.handleBrowserOpen(browser, "s1");
3048
+ browser.send.mockClear();
3049
+ });
3050
+
3051
+ it("skips empty lines between valid NDJSON", async () => {
3052
+ const validMsg = JSON.stringify({
3053
+ type: "tool_progress",
3054
+ tool_use_id: "tu-empty-lines",
3055
+ tool_name: "Bash",
3056
+ parent_tool_use_id: null,
3057
+ elapsed_time_seconds: 1,
3058
+ uuid: "uuid-empty-lines",
3059
+ session_id: "s1",
3060
+ });
3061
+
3062
+ // Empty lines, whitespace-only lines interspersed
3063
+ const raw = "\n\n" + validMsg + "\n\n \n\t\n";
3064
+ await bridge.handleCLIMessage(cli, raw);
3065
+
3066
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
3067
+ expect(calls).toHaveLength(1);
3068
+ expect(calls[0].type).toBe("tool_progress");
3069
+ expect(calls[0].tool_use_id).toBe("tu-empty-lines");
3070
+ });
3071
+
3072
+ it("handles entirely empty/whitespace input without crashing", async () => {
3073
+ await bridge.handleCLIMessage(cli, "");
3074
+ await bridge.handleCLIMessage(cli, "\n\n\n");
3075
+ await bridge.handleCLIMessage(cli, " \t \n ");
3076
+ expect(browser.send).not.toHaveBeenCalled();
3077
+ });
3078
+
3079
+ it("processes valid lines around whitespace-only lines", async () => {
3080
+ const line1 = JSON.stringify({
3081
+ type: "tool_progress",
3082
+ tool_use_id: "tu-ws-1",
3083
+ tool_name: "Read",
3084
+ parent_tool_use_id: null,
3085
+ elapsed_time_seconds: 1,
3086
+ uuid: "uuid-ws-1",
3087
+ session_id: "s1",
3088
+ });
3089
+ const line2 = JSON.stringify({
3090
+ type: "tool_progress",
3091
+ tool_use_id: "tu-ws-2",
3092
+ tool_name: "Edit",
3093
+ parent_tool_use_id: null,
3094
+ elapsed_time_seconds: 2,
3095
+ uuid: "uuid-ws-2",
3096
+ session_id: "s1",
3097
+ });
3098
+
3099
+ const raw = line1 + "\n \n\n" + line2 + "\n";
3100
+ await bridge.handleCLIMessage(cli, raw);
3101
+
3102
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
3103
+ const progressMsgs = calls.filter((c: any) => c.type === "tool_progress");
3104
+ expect(progressMsgs).toHaveLength(2);
3105
+ expect(progressMsgs[0].tool_use_id).toBe("tu-ws-1");
3106
+ expect(progressMsgs[1].tool_use_id).toBe("tu-ws-2");
3107
+ });
3108
+ });
3109
+
3110
+ // ─── Session not found scenarios ──────────────────────────────────────────────
3111
+
3112
+ describe("Session not found scenarios", () => {
3113
+ it("handleCLIMessage does nothing for unknown session", async () => {
3114
+ const cli = makeCliSocket("unknown-session");
3115
+ // Do NOT call handleCLIOpen — session does not exist in the bridge
3116
+
3117
+ // Should not throw (async — just await it directly)
3118
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3119
+ type: "tool_progress",
3120
+ tool_use_id: "tu-unknown",
3121
+ tool_name: "Bash",
3122
+ parent_tool_use_id: null,
3123
+ elapsed_time_seconds: 1,
3124
+ uuid: "uuid-unknown",
3125
+ session_id: "unknown-session",
3126
+ }));
3127
+
3128
+ // Session should not have been created
3129
+ expect(bridge.getSession("unknown-session")).toBeUndefined();
3130
+ });
3131
+
3132
+ it("handleCLIClose does nothing for unknown session", () => {
3133
+ const cli = makeCliSocket("nonexistent");
3134
+
3135
+ expect(() => {
3136
+ bridge.handleCLIClose(cli);
3137
+ }).not.toThrow();
3138
+
3139
+ expect(bridge.getSession("nonexistent")).toBeUndefined();
3140
+ });
3141
+
3142
+ it("handleBrowserClose does nothing for unknown session", () => {
3143
+ const browser = makeBrowserSocket("nonexistent");
3144
+
3145
+ expect(() => {
3146
+ bridge.handleBrowserClose(browser);
3147
+ }).not.toThrow();
3148
+
3149
+ expect(bridge.getSession("nonexistent")).toBeUndefined();
3150
+ });
3151
+
3152
+ it("handleBrowserMessage does nothing for unknown session", () => {
3153
+ const browser = makeBrowserSocket("nonexistent");
3154
+
3155
+ expect(() => {
3156
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3157
+ type: "user_message",
3158
+ content: "hello",
3159
+ }));
3160
+ }).not.toThrow();
3161
+
3162
+ expect(bridge.getSession("nonexistent")).toBeUndefined();
3163
+ });
3164
+ });
3165
+
3166
+ // ─── Restore from disk with pendingPermissions ───────────────────────────────
3167
+
3168
+ describe("Restore from disk with pendingPermissions", () => {
3169
+ it("restores sessions with pending permissions as a Map", () => {
3170
+ const pendingPerms: [string, any][] = [
3171
+ ["req-restored-1", {
3172
+ request_id: "req-restored-1",
3173
+ tool_name: "Bash",
3174
+ input: { command: "rm -rf /tmp/test" },
3175
+ tool_use_id: "tu-restored-1",
3176
+ timestamp: 1700000000000,
3177
+ }],
3178
+ ["req-restored-2", {
3179
+ request_id: "req-restored-2",
3180
+ tool_name: "Edit",
3181
+ input: { file_path: "/test.ts" },
3182
+ description: "Edit file",
3183
+ tool_use_id: "tu-restored-2",
3184
+ agent_id: "agent-1",
3185
+ timestamp: 1700000001000,
3186
+ }],
3187
+ ];
3188
+
3189
+ store.saveSync({
3190
+ id: "perm-session",
3191
+ state: {
3192
+ session_id: "perm-session",
3193
+ model: "claude-sonnet-4-6",
3194
+ cwd: "/test",
3195
+ tools: ["Bash", "Edit"],
3196
+ permissionMode: "default",
3197
+ claude_code_version: "1.0",
3198
+ mcp_servers: [],
3199
+ agents: [],
3200
+ slash_commands: [],
3201
+ skills: [],
3202
+ total_cost_usd: 0,
3203
+ num_turns: 0,
3204
+ context_used_percent: 0,
3205
+ is_compacting: false,
3206
+ git_branch: "",
3207
+ is_worktree: false,
3208
+ is_containerized: false,
3209
+ repo_root: "",
3210
+ git_ahead: 0,
3211
+ git_behind: 0,
3212
+ total_lines_added: 0,
3213
+ total_lines_removed: 0,
3214
+ },
3215
+ messageHistory: [],
3216
+ pendingMessages: [],
3217
+ pendingPermissions: pendingPerms,
3218
+ });
3219
+
3220
+ const count = bridge.restoreFromDisk();
3221
+ expect(count).toBe(1);
3222
+
3223
+ const session = bridge.getSession("perm-session")!;
3224
+ expect(session.pendingPermissions).toBeInstanceOf(Map);
3225
+ expect(session.pendingPermissions.size).toBe(2);
3226
+
3227
+ const perm1 = session.pendingPermissions.get("req-restored-1")!;
3228
+ expect(perm1.tool_name).toBe("Bash");
3229
+ expect(perm1.input).toEqual({ command: "rm -rf /tmp/test" });
3230
+ expect(perm1.tool_use_id).toBe("tu-restored-1");
3231
+ expect(perm1.timestamp).toBe(1700000000000);
3232
+
3233
+ const perm2 = session.pendingPermissions.get("req-restored-2")!;
3234
+ expect(perm2.tool_name).toBe("Edit");
3235
+ expect(perm2.description).toBe("Edit file");
3236
+ expect(perm2.agent_id).toBe("agent-1");
3237
+ });
3238
+
3239
+ it("restored pending permissions are sent to newly connected browsers", () => {
3240
+ store.saveSync({
3241
+ id: "perm-replay",
3242
+ state: {
3243
+ session_id: "perm-replay",
3244
+ model: "claude-sonnet-4-6",
3245
+ cwd: "/test",
3246
+ tools: ["Bash"],
3247
+ permissionMode: "default",
3248
+ claude_code_version: "1.0",
3249
+ mcp_servers: [],
3250
+ agents: [],
3251
+ slash_commands: [],
3252
+ skills: [],
3253
+ total_cost_usd: 0,
3254
+ num_turns: 0,
3255
+ context_used_percent: 0,
3256
+ is_compacting: false,
3257
+ git_branch: "",
3258
+ is_worktree: false,
3259
+ is_containerized: false,
3260
+ repo_root: "",
3261
+ git_ahead: 0,
3262
+ git_behind: 0,
3263
+ total_lines_added: 0,
3264
+ total_lines_removed: 0,
3265
+ },
3266
+ messageHistory: [],
3267
+ pendingMessages: [],
3268
+ pendingPermissions: [
3269
+ ["req-replay", {
3270
+ request_id: "req-replay",
3271
+ tool_name: "Bash",
3272
+ input: { command: "echo test" },
3273
+ tool_use_id: "tu-replay",
3274
+ timestamp: 1700000000000,
3275
+ }],
3276
+ ],
3277
+ });
3278
+
3279
+ bridge.restoreFromDisk();
3280
+
3281
+ // Connect a CLI so we don't trigger relaunch
3282
+ const cli = makeCliSocket("perm-replay");
3283
+ bridge.handleCLIOpen(cli, "perm-replay");
3284
+
3285
+ // Now connect a browser
3286
+ const browser = makeBrowserSocket("perm-replay");
3287
+ bridge.handleBrowserOpen(browser, "perm-replay");
3288
+
3289
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
3290
+ const permMsg = calls.find((c: any) => c.type === "permission_request");
3291
+ expect(permMsg).toBeDefined();
3292
+ expect(permMsg.request.request_id).toBe("req-replay");
3293
+ expect(permMsg.request.tool_name).toBe("Bash");
3294
+ expect(permMsg.request.input).toEqual({ command: "echo test" });
3295
+ });
3296
+
3297
+ it("restores sessions with empty pendingPermissions array", () => {
3298
+ store.saveSync({
3299
+ id: "empty-perms",
3300
+ state: {
3301
+ session_id: "empty-perms",
3302
+ model: "claude-sonnet-4-6",
3303
+ cwd: "/test",
3304
+ tools: [],
3305
+ permissionMode: "default",
3306
+ claude_code_version: "1.0",
3307
+ mcp_servers: [],
3308
+ agents: [],
3309
+ slash_commands: [],
3310
+ skills: [],
3311
+ total_cost_usd: 0,
3312
+ num_turns: 0,
3313
+ context_used_percent: 0,
3314
+ is_compacting: false,
3315
+ git_branch: "",
3316
+ is_worktree: false,
3317
+ is_containerized: false,
3318
+ repo_root: "",
3319
+ git_ahead: 0,
3320
+ git_behind: 0,
3321
+ total_lines_added: 0,
3322
+ total_lines_removed: 0,
3323
+ },
3324
+ messageHistory: [],
3325
+ pendingMessages: [],
3326
+ pendingPermissions: [],
3327
+ });
3328
+
3329
+ const count = bridge.restoreFromDisk();
3330
+ expect(count).toBe(1);
3331
+
3332
+ const session = bridge.getSession("empty-perms")!;
3333
+ expect(session.pendingPermissions).toBeInstanceOf(Map);
3334
+ expect(session.pendingPermissions.size).toBe(0);
3335
+ });
3336
+
3337
+ it("restores sessions with undefined pendingPermissions", () => {
3338
+ // Simulate a persisted session from an older version that lacks pendingPermissions
3339
+ store.saveSync({
3340
+ id: "no-perms-field",
3341
+ state: {
3342
+ session_id: "no-perms-field",
3343
+ model: "claude-sonnet-4-6",
3344
+ cwd: "/test",
3345
+ tools: [],
3346
+ permissionMode: "default",
3347
+ claude_code_version: "1.0",
3348
+ mcp_servers: [],
3349
+ agents: [],
3350
+ slash_commands: [],
3351
+ skills: [],
3352
+ total_cost_usd: 0,
3353
+ num_turns: 0,
3354
+ context_used_percent: 0,
3355
+ is_compacting: false,
3356
+ git_branch: "",
3357
+ is_worktree: false,
3358
+ is_containerized: false,
3359
+ repo_root: "",
3360
+ git_ahead: 0,
3361
+ git_behind: 0,
3362
+ total_lines_added: 0,
3363
+ total_lines_removed: 0,
3364
+ },
3365
+ messageHistory: [],
3366
+ pendingMessages: [],
3367
+ // Cast to bypass TypeScript — simulating missing field from older persisted data
3368
+ pendingPermissions: undefined as any,
3369
+ });
3370
+
3371
+ const count = bridge.restoreFromDisk();
3372
+ expect(count).toBe(1);
3373
+
3374
+ const session = bridge.getSession("no-perms-field")!;
3375
+ expect(session.pendingPermissions).toBeInstanceOf(Map);
3376
+ expect(session.pendingPermissions.size).toBe(0);
3377
+ });
3378
+ });
3379
+
3380
+ // ─── First turn callback ──────────────────────────────────────────────────────
3381
+
3382
+ describe("onFirstTurnCompletedCallback", () => {
3383
+ it("fires on first successful result regardless of num_turns", async () => {
3384
+ const callback = vi.fn();
3385
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3386
+
3387
+ const cli = makeCliSocket("s1");
3388
+ bridge.handleCLIOpen(cli, "s1");
3389
+ await bridge.handleCLIMessage(cli, makeInitMsg());
3390
+
3391
+ // Simulate a browser sending a user message
3392
+ const browser = makeBrowserSocket("s1");
3393
+ bridge.handleBrowserOpen(browser, "s1");
3394
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3395
+ type: "user_message",
3396
+ content: "Fix the login bug",
3397
+ }));
3398
+
3399
+ // Simulate the result — num_turns is 5 because CLI auto-approved tool calls
3400
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3401
+ type: "result",
3402
+ subtype: "success",
3403
+ is_error: false,
3404
+ result: "Done",
3405
+ duration_ms: 1000,
3406
+ duration_api_ms: 800,
3407
+ num_turns: 5,
3408
+ total_cost_usd: 0.01,
3409
+ stop_reason: "end_turn",
3410
+ usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3411
+ uuid: "uuid-first",
3412
+ session_id: "s1",
3413
+ }));
3414
+
3415
+ expect(callback).toHaveBeenCalledWith("s1", "Fix the login bug");
3416
+ });
3417
+
3418
+ it("does not fire on subsequent results for the same session", async () => {
3419
+ const callback = vi.fn();
3420
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3421
+
3422
+ const cli = makeCliSocket("s1");
3423
+ bridge.handleCLIOpen(cli, "s1");
3424
+ await bridge.handleCLIMessage(cli, makeInitMsg());
3425
+
3426
+ const browser = makeBrowserSocket("s1");
3427
+ bridge.handleBrowserOpen(browser, "s1");
3428
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3429
+ type: "user_message",
3430
+ content: "First message",
3431
+ }));
3432
+
3433
+ // First result — triggers callback
3434
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3435
+ type: "result",
3436
+ subtype: "success",
3437
+ is_error: false,
3438
+ duration_ms: 1000,
3439
+ duration_api_ms: 800,
3440
+ num_turns: 3,
3441
+ total_cost_usd: 0.05,
3442
+ stop_reason: "end_turn",
3443
+ usage: { input_tokens: 500, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3444
+ uuid: "uuid-first",
3445
+ session_id: "s1",
3446
+ }));
3447
+
3448
+ expect(callback).toHaveBeenCalledTimes(1);
3449
+
3450
+ // Second user message + result — should NOT trigger callback again
3451
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3452
+ type: "user_message",
3453
+ content: "Second message",
3454
+ }));
3455
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3456
+ type: "result",
3457
+ subtype: "success",
3458
+ is_error: false,
3459
+ duration_ms: 1000,
3460
+ duration_api_ms: 800,
3461
+ num_turns: 6,
3462
+ total_cost_usd: 0.10,
3463
+ stop_reason: "end_turn",
3464
+ usage: { input_tokens: 800, output_tokens: 300, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3465
+ uuid: "uuid-second",
3466
+ session_id: "s1",
3467
+ }));
3468
+
3469
+ expect(callback).toHaveBeenCalledTimes(1);
3470
+ });
3471
+
3472
+ it("does not fire on error results", async () => {
3473
+ const callback = vi.fn();
3474
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3475
+
3476
+ const cli = makeCliSocket("s1");
3477
+ bridge.handleCLIOpen(cli, "s1");
3478
+ await bridge.handleCLIMessage(cli, makeInitMsg());
3479
+
3480
+ const browser = makeBrowserSocket("s1");
3481
+ bridge.handleBrowserOpen(browser, "s1");
3482
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3483
+ type: "user_message",
3484
+ content: "Some request",
3485
+ }));
3486
+
3487
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3488
+ type: "result",
3489
+ subtype: "error_during_execution",
3490
+ is_error: true,
3491
+ errors: ["Something went wrong"],
3492
+ duration_ms: 500,
3493
+ duration_api_ms: 400,
3494
+ num_turns: 1,
3495
+ total_cost_usd: 0.005,
3496
+ stop_reason: null,
3497
+ usage: { input_tokens: 50, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3498
+ uuid: "uuid-err",
3499
+ session_id: "s1",
3500
+ }));
3501
+
3502
+ expect(callback).not.toHaveBeenCalled();
3503
+ });
3504
+
3505
+ it("fires after initial error result followed by a successful result", async () => {
3506
+ const callback = vi.fn();
3507
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3508
+
3509
+ const cli = makeCliSocket("s1");
3510
+ bridge.handleCLIOpen(cli, "s1");
3511
+ await bridge.handleCLIMessage(cli, makeInitMsg());
3512
+
3513
+ const browser = makeBrowserSocket("s1");
3514
+ bridge.handleBrowserOpen(browser, "s1");
3515
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3516
+ type: "user_message",
3517
+ content: "Fix the bug",
3518
+ }));
3519
+
3520
+ // First result is an error — should NOT trigger
3521
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3522
+ type: "result",
3523
+ subtype: "error_during_execution",
3524
+ is_error: true,
3525
+ errors: ["Oops"],
3526
+ duration_ms: 100,
3527
+ duration_api_ms: 50,
3528
+ num_turns: 1,
3529
+ total_cost_usd: 0.001,
3530
+ stop_reason: null,
3531
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3532
+ uuid: "uuid-err",
3533
+ session_id: "s1",
3534
+ }));
3535
+ expect(callback).not.toHaveBeenCalled();
3536
+
3537
+ // Second result is success — should trigger since no successful result yet
3538
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3539
+ type: "result",
3540
+ subtype: "success",
3541
+ is_error: false,
3542
+ result: "Done",
3543
+ duration_ms: 500,
3544
+ duration_api_ms: 400,
3545
+ num_turns: 3,
3546
+ total_cost_usd: 0.01,
3547
+ stop_reason: "end_turn",
3548
+ usage: { input_tokens: 100, output_tokens: 50, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3549
+ uuid: "uuid-ok",
3550
+ session_id: "s1",
3551
+ }));
3552
+ expect(callback).toHaveBeenCalledWith("s1", "Fix the bug");
3553
+ expect(callback).toHaveBeenCalledTimes(1);
3554
+ });
3555
+
3556
+ it("does not fire when there is no user message in history", async () => {
3557
+ const callback = vi.fn();
3558
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3559
+
3560
+ const cli = makeCliSocket("s1");
3561
+ bridge.handleCLIOpen(cli, "s1");
3562
+ await bridge.handleCLIMessage(cli, makeInitMsg());
3563
+
3564
+ // Send result without any user message first
3565
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3566
+ type: "result",
3567
+ subtype: "success",
3568
+ is_error: false,
3569
+ result: "Done",
3570
+ duration_ms: 100,
3571
+ duration_api_ms: 50,
3572
+ num_turns: 1,
3573
+ total_cost_usd: 0.001,
3574
+ stop_reason: "end_turn",
3575
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3576
+ uuid: "uuid-1",
3577
+ session_id: "s1",
3578
+ }));
3579
+
3580
+ expect(callback).not.toHaveBeenCalled();
3581
+ });
3582
+
3583
+ it("fires independently for different sessions", async () => {
3584
+ const callback = vi.fn();
3585
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3586
+
3587
+ // Setup session 1
3588
+ const cli1 = makeCliSocket("s1");
3589
+ bridge.handleCLIOpen(cli1, "s1");
3590
+ await bridge.handleCLIMessage(cli1, makeInitMsg());
3591
+ const browser1 = makeBrowserSocket("s1");
3592
+ bridge.handleBrowserOpen(browser1, "s1");
3593
+ bridge.handleBrowserMessage(browser1, JSON.stringify({
3594
+ type: "user_message",
3595
+ content: "Message for s1",
3596
+ }));
3597
+
3598
+ // Setup session 2
3599
+ const cli2 = makeCliSocket("s2");
3600
+ bridge.handleCLIOpen(cli2, "s2");
3601
+ await bridge.handleCLIMessage(cli2, makeInitMsg());
3602
+ const browser2 = makeBrowserSocket("s2");
3603
+ bridge.handleBrowserOpen(browser2, "s2");
3604
+ bridge.handleBrowserMessage(browser2, JSON.stringify({
3605
+ type: "user_message",
3606
+ content: "Message for s2",
3607
+ }));
3608
+
3609
+ // Result for s1
3610
+ await bridge.handleCLIMessage(cli1, JSON.stringify({
3611
+ type: "result",
3612
+ subtype: "success",
3613
+ is_error: false,
3614
+ duration_ms: 100,
3615
+ duration_api_ms: 50,
3616
+ num_turns: 2,
3617
+ total_cost_usd: 0.01,
3618
+ stop_reason: "end_turn",
3619
+ usage: { input_tokens: 50, output_tokens: 25, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3620
+ uuid: "uuid-s1",
3621
+ session_id: "s1",
3622
+ }));
3623
+
3624
+ expect(callback).toHaveBeenCalledTimes(1);
3625
+ expect(callback).toHaveBeenCalledWith("s1", "Message for s1");
3626
+
3627
+ // Result for s2 — should also fire (independent session)
3628
+ await bridge.handleCLIMessage(cli2, JSON.stringify({
3629
+ type: "result",
3630
+ subtype: "success",
3631
+ is_error: false,
3632
+ duration_ms: 100,
3633
+ duration_api_ms: 50,
3634
+ num_turns: 4,
3635
+ total_cost_usd: 0.02,
3636
+ stop_reason: "end_turn",
3637
+ usage: { input_tokens: 80, output_tokens: 40, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3638
+ uuid: "uuid-s2",
3639
+ session_id: "s2",
3640
+ }));
3641
+
3642
+ expect(callback).toHaveBeenCalledTimes(2);
3643
+ expect(callback).toHaveBeenCalledWith("s2", "Message for s2");
3644
+ });
3645
+
3646
+ it("cleans up auto-naming tracking when session is removed", async () => {
3647
+ const callback = vi.fn();
3648
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3649
+
3650
+ const cli = makeCliSocket("s1");
3651
+ bridge.handleCLIOpen(cli, "s1");
3652
+ await bridge.handleCLIMessage(cli, makeInitMsg());
3653
+ const browser = makeBrowserSocket("s1");
3654
+ bridge.handleBrowserOpen(browser, "s1");
3655
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3656
+ type: "user_message",
3657
+ content: "Hello",
3658
+ }));
3659
+
3660
+ // First result triggers callback
3661
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3662
+ type: "result",
3663
+ subtype: "success",
3664
+ is_error: false,
3665
+ duration_ms: 100,
3666
+ duration_api_ms: 50,
3667
+ num_turns: 1,
3668
+ total_cost_usd: 0.01,
3669
+ stop_reason: "end_turn",
3670
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3671
+ uuid: "uuid-1",
3672
+ session_id: "s1",
3673
+ }));
3674
+ expect(callback).toHaveBeenCalledTimes(1);
3675
+
3676
+ // Remove and recreate the session
3677
+ bridge.removeSession("s1");
3678
+ const cli2 = makeCliSocket("s1");
3679
+ bridge.handleCLIOpen(cli2, "s1");
3680
+ await bridge.handleCLIMessage(cli2, makeInitMsg());
3681
+ const browser2 = makeBrowserSocket("s1");
3682
+ bridge.handleBrowserOpen(browser2, "s1");
3683
+ bridge.handleBrowserMessage(browser2, JSON.stringify({
3684
+ type: "user_message",
3685
+ content: "Hello again",
3686
+ }));
3687
+
3688
+ // Should fire again for the recreated session
3689
+ await bridge.handleCLIMessage(cli2, JSON.stringify({
3690
+ type: "result",
3691
+ subtype: "success",
3692
+ is_error: false,
3693
+ duration_ms: 100,
3694
+ duration_api_ms: 50,
3695
+ num_turns: 2,
3696
+ total_cost_usd: 0.01,
3697
+ stop_reason: "end_turn",
3698
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3699
+ uuid: "uuid-2",
3700
+ session_id: "s1",
3701
+ }));
3702
+ expect(callback).toHaveBeenCalledTimes(2);
3703
+ expect(callback).toHaveBeenLastCalledWith("s1", "Hello again");
3704
+ });
3705
+
3706
+ it("cleans up auto-naming tracking when session is closed", async () => {
3707
+ const callback = vi.fn();
3708
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3709
+
3710
+ const cli = makeCliSocket("s1");
3711
+ bridge.handleCLIOpen(cli, "s1");
3712
+ await bridge.handleCLIMessage(cli, makeInitMsg());
3713
+ const browser = makeBrowserSocket("s1");
3714
+ bridge.handleBrowserOpen(browser, "s1");
3715
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3716
+ type: "user_message",
3717
+ content: "First session",
3718
+ }));
3719
+
3720
+ // Trigger callback
3721
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3722
+ type: "result",
3723
+ subtype: "success",
3724
+ is_error: false,
3725
+ duration_ms: 100,
3726
+ duration_api_ms: 50,
3727
+ num_turns: 1,
3728
+ total_cost_usd: 0.01,
3729
+ stop_reason: "end_turn",
3730
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3731
+ uuid: "uuid-1",
3732
+ session_id: "s1",
3733
+ }));
3734
+ expect(callback).toHaveBeenCalledTimes(1);
3735
+
3736
+ // Close session (should clean up tracking)
3737
+ bridge.closeSession("s1");
3738
+
3739
+ // Recreate and verify callback fires again
3740
+ const cli2 = makeCliSocket("s1");
3741
+ bridge.handleCLIOpen(cli2, "s1");
3742
+ await bridge.handleCLIMessage(cli2, makeInitMsg());
3743
+ const browser2 = makeBrowserSocket("s1");
3744
+ bridge.handleBrowserOpen(browser2, "s1");
3745
+ bridge.handleBrowserMessage(browser2, JSON.stringify({
3746
+ type: "user_message",
3747
+ content: "Second session",
3748
+ }));
3749
+ await bridge.handleCLIMessage(cli2, JSON.stringify({
3750
+ type: "result",
3751
+ subtype: "success",
3752
+ is_error: false,
3753
+ duration_ms: 100,
3754
+ duration_api_ms: 50,
3755
+ num_turns: 1,
3756
+ total_cost_usd: 0.01,
3757
+ stop_reason: "end_turn",
3758
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3759
+ uuid: "uuid-2",
3760
+ session_id: "s1",
3761
+ }));
3762
+ expect(callback).toHaveBeenCalledTimes(2);
3763
+ });
3764
+
3765
+ it("does not fire for restored sessions with completed turns", async () => {
3766
+ const callback = vi.fn();
3767
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3768
+
3769
+ // Persist a session with num_turns > 0 and a user message in history
3770
+ store.save({
3771
+ id: "restored-1",
3772
+ state: {
3773
+ session_id: "restored-1",
3774
+ model: "claude-sonnet-4-6",
3775
+ cwd: "/test",
3776
+ tools: [],
3777
+ permissionMode: "default",
3778
+ claude_code_version: "1.0",
3779
+ mcp_servers: [],
3780
+ agents: [],
3781
+ slash_commands: [],
3782
+ skills: [],
3783
+ total_cost_usd: 0.01,
3784
+ num_turns: 3,
3785
+ context_used_percent: 10,
3786
+ is_compacting: false,
3787
+ git_branch: "",
3788
+ is_worktree: false,
3789
+ is_containerized: false,
3790
+ repo_root: "",
3791
+ git_ahead: 0,
3792
+ git_behind: 0,
3793
+ total_lines_added: 0,
3794
+ total_lines_removed: 0,
3795
+ },
3796
+ messageHistory: [
3797
+ { type: "user_message" as const, content: "Build the app", timestamp: Date.now() },
3798
+ ],
3799
+ pendingMessages: [],
3800
+ pendingPermissions: [],
3801
+ });
3802
+
3803
+ // Restore from disk — this should mark the session as auto-naming attempted
3804
+ bridge.restoreFromDisk();
3805
+
3806
+ // CLI reconnects
3807
+ const cli = makeCliSocket("restored-1");
3808
+ bridge.handleCLIOpen(cli, "restored-1");
3809
+
3810
+ // Another result comes in — should NOT trigger callback
3811
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3812
+ type: "result",
3813
+ subtype: "success",
3814
+ is_error: false,
3815
+ duration_ms: 200,
3816
+ duration_api_ms: 150,
3817
+ num_turns: 5,
3818
+ total_cost_usd: 0.02,
3819
+ stop_reason: "end_turn",
3820
+ usage: { input_tokens: 200, output_tokens: 100, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3821
+ uuid: "uuid-restored",
3822
+ session_id: "restored-1",
3823
+ }));
3824
+
3825
+ expect(callback).not.toHaveBeenCalled();
3826
+ });
3827
+
3828
+ it("allows auto-naming for restored sessions with zero turns", async () => {
3829
+ const callback = vi.fn();
3830
+ companionBus.on("session:first-turn-completed", ({ sessionId, firstUserMessage }) => callback(sessionId, firstUserMessage));
3831
+
3832
+ // Persist a session with num_turns === 0 (brand new, never completed a turn)
3833
+ store.save({
3834
+ id: "fresh-restored",
3835
+ state: {
3836
+ session_id: "fresh-restored",
3837
+ model: "claude-sonnet-4-6",
3838
+ cwd: "/test",
3839
+ tools: [],
3840
+ permissionMode: "default",
3841
+ claude_code_version: "1.0",
3842
+ mcp_servers: [],
3843
+ agents: [],
3844
+ slash_commands: [],
3845
+ skills: [],
3846
+ total_cost_usd: 0,
3847
+ num_turns: 0,
3848
+ context_used_percent: 0,
3849
+ is_compacting: false,
3850
+ git_branch: "",
3851
+ is_worktree: false,
3852
+ is_containerized: false,
3853
+ repo_root: "",
3854
+ git_ahead: 0,
3855
+ git_behind: 0,
3856
+ total_lines_added: 0,
3857
+ total_lines_removed: 0,
3858
+ },
3859
+ messageHistory: [],
3860
+ pendingMessages: [],
3861
+ pendingPermissions: [],
3862
+ });
3863
+
3864
+ bridge.restoreFromDisk();
3865
+
3866
+ // CLI connects and browser sends message
3867
+ const cli = makeCliSocket("fresh-restored");
3868
+ bridge.handleCLIOpen(cli, "fresh-restored");
3869
+ await bridge.handleCLIMessage(cli, makeInitMsg());
3870
+ const browser = makeBrowserSocket("fresh-restored");
3871
+ bridge.handleBrowserOpen(browser, "fresh-restored");
3872
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3873
+ type: "user_message",
3874
+ content: "Hello world",
3875
+ }));
3876
+
3877
+ await bridge.handleCLIMessage(cli, JSON.stringify({
3878
+ type: "result",
3879
+ subtype: "success",
3880
+ is_error: false,
3881
+ duration_ms: 100,
3882
+ duration_api_ms: 50,
3883
+ num_turns: 2,
3884
+ total_cost_usd: 0.01,
3885
+ stop_reason: "end_turn",
3886
+ usage: { input_tokens: 50, output_tokens: 25, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
3887
+ uuid: "uuid-fresh",
3888
+ session_id: "fresh-restored",
3889
+ }));
3890
+
3891
+ expect(callback).toHaveBeenCalledWith("fresh-restored", "Hello world");
3892
+ });
3893
+ });
3894
+
3895
+ // ─── broadcastNameUpdate ──────────────────────────────────────────────────────
3896
+
3897
+ describe("broadcastNameUpdate", () => {
3898
+ it("sends session_name_update to connected browsers", () => {
3899
+ const cli = makeCliSocket("s1");
3900
+ bridge.handleCLIOpen(cli, "s1");
3901
+
3902
+ const browser1 = makeBrowserSocket("s1");
3903
+ const browser2 = makeBrowserSocket("s1");
3904
+ bridge.handleBrowserOpen(browser1, "s1");
3905
+ bridge.handleBrowserOpen(browser2, "s1");
3906
+
3907
+ bridge.broadcastNameUpdate("s1", "Fix Auth Bug");
3908
+
3909
+ const calls1 = browser1.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
3910
+ const calls2 = browser2.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
3911
+ expect(calls1).toContainEqual(expect.objectContaining({ type: "session_name_update", name: "Fix Auth Bug" }));
3912
+ expect(calls2).toContainEqual(expect.objectContaining({ type: "session_name_update", name: "Fix Auth Bug" }));
3913
+ });
3914
+
3915
+ it("does nothing for unknown sessions", async () => {
3916
+ // Should not throw
3917
+ bridge.broadcastNameUpdate("nonexistent", "Name");
3918
+ });
3919
+ });
3920
+
3921
+ // ─── MCP Control Messages ────────────────────────────────────────────────────
3922
+
3923
+ describe("MCP control messages", () => {
3924
+ let cli: ReturnType<typeof makeCliSocket>;
3925
+ let browser: ReturnType<typeof makeBrowserSocket>;
3926
+
3927
+ beforeEach(async () => {
3928
+ cli = makeCliSocket("s1");
3929
+ browser = makeBrowserSocket("s1");
3930
+ bridge.handleCLIOpen(cli, "s1");
3931
+ bridge.handleBrowserOpen(browser, "s1");
3932
+ await bridge.handleCLIMessage(cli, makeInitMsg());
3933
+ cli.send.mockClear();
3934
+ browser.send.mockClear();
3935
+ });
3936
+
3937
+ it("mcp_get_status: sends mcp_status control_request to CLI", () => {
3938
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3939
+ type: "mcp_get_status",
3940
+ }));
3941
+
3942
+ expect(cli.send).toHaveBeenCalledTimes(1);
3943
+ const sentRaw = cli.send.mock.calls[0][0] as string;
3944
+ const sent = JSON.parse(sentRaw.trim());
3945
+ expect(sent.type).toBe("control_request");
3946
+ expect(sent.request_id).toBe("test-uuid");
3947
+ expect(sent.request.subtype).toBe("mcp_status");
3948
+ });
3949
+
3950
+ it("mcp_toggle: sends mcp_toggle control_request to CLI", () => {
3951
+ // Use vi.useFakeTimers to prevent the delayed mcp_get_status
3952
+ vi.useFakeTimers();
3953
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3954
+ type: "mcp_toggle",
3955
+ serverName: "my-server",
3956
+ enabled: false,
3957
+ }));
3958
+
3959
+ expect(cli.send).toHaveBeenCalledTimes(1);
3960
+ const sentRaw = cli.send.mock.calls[0][0] as string;
3961
+ const sent = JSON.parse(sentRaw.trim());
3962
+ expect(sent.type).toBe("control_request");
3963
+ expect(sent.request.subtype).toBe("mcp_toggle");
3964
+ expect(sent.request.serverName).toBe("my-server");
3965
+ expect(sent.request.enabled).toBe(false);
3966
+ vi.useRealTimers();
3967
+ });
3968
+
3969
+ it("mcp_reconnect: sends mcp_reconnect control_request to CLI", () => {
3970
+ vi.useFakeTimers();
3971
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3972
+ type: "mcp_reconnect",
3973
+ serverName: "failing-server",
3974
+ }));
3975
+
3976
+ expect(cli.send).toHaveBeenCalledTimes(1);
3977
+ const sentRaw = cli.send.mock.calls[0][0] as string;
3978
+ const sent = JSON.parse(sentRaw.trim());
3979
+ expect(sent.type).toBe("control_request");
3980
+ expect(sent.request.subtype).toBe("mcp_reconnect");
3981
+ expect(sent.request.serverName).toBe("failing-server");
3982
+ vi.useRealTimers();
3983
+ });
3984
+
3985
+ it("control_response for mcp_status: broadcasts mcp_status to browsers", async () => {
3986
+ // Send mcp_get_status to create the pending request
3987
+ bridge.handleBrowserMessage(browser, JSON.stringify({
3988
+ type: "mcp_get_status",
3989
+ }));
3990
+ browser.send.mockClear();
3991
+
3992
+ // Simulate CLI responding with control_response
3993
+ const mockServers = [
3994
+ {
3995
+ name: "test-server",
3996
+ status: "connected",
3997
+ config: { type: "stdio", command: "node", args: ["server.js"] },
3998
+ scope: "project",
3999
+ tools: [{ name: "myTool" }],
4000
+ },
4001
+ ];
4002
+
4003
+ await bridge.handleCLIMessage(cli, JSON.stringify({
4004
+ type: "control_response",
4005
+ response: {
4006
+ subtype: "success",
4007
+ request_id: "test-uuid",
4008
+ response: { mcpServers: mockServers },
4009
+ },
4010
+ }));
4011
+
4012
+ expect(browser.send).toHaveBeenCalledTimes(1);
4013
+ const browserMsg = JSON.parse(browser.send.mock.calls[0][0] as string);
4014
+ expect(browserMsg.type).toBe("mcp_status");
4015
+ expect(browserMsg.servers).toHaveLength(1);
4016
+ expect(browserMsg.servers[0].name).toBe("test-server");
4017
+ expect(browserMsg.servers[0].status).toBe("connected");
4018
+ expect(browserMsg.servers[0].tools).toHaveLength(1);
4019
+ });
4020
+
4021
+ it("control_response with error: does not broadcast to browsers", async () => {
4022
+ bridge.handleBrowserMessage(browser, JSON.stringify({
4023
+ type: "mcp_get_status",
4024
+ }));
4025
+ browser.send.mockClear();
4026
+
4027
+ await bridge.handleCLIMessage(cli, JSON.stringify({
4028
+ type: "control_response",
4029
+ response: {
4030
+ subtype: "error",
4031
+ request_id: "test-uuid",
4032
+ error: "MCP not available",
4033
+ },
4034
+ }));
4035
+
4036
+ // Should not broadcast anything
4037
+ expect(browser.send).not.toHaveBeenCalled();
4038
+ });
4039
+
4040
+ it("control_response for unknown request_id: ignored silently", async () => {
4041
+ await bridge.handleCLIMessage(cli, JSON.stringify({
4042
+ type: "control_response",
4043
+ response: {
4044
+ subtype: "success",
4045
+ request_id: "unknown-id",
4046
+ response: { mcpServers: [] },
4047
+ },
4048
+ }));
4049
+
4050
+ // Should not throw and not send anything
4051
+ expect(browser.send).not.toHaveBeenCalled();
4052
+ });
4053
+
4054
+ it("mcp_set_servers: sends mcp_set_servers control_request to CLI", () => {
4055
+ vi.useFakeTimers();
4056
+ const servers = {
4057
+ "my-notes": {
4058
+ type: "stdio" as const,
4059
+ command: "npx",
4060
+ args: ["-y", "@modelcontextprotocol/server-memory"],
4061
+ },
4062
+ };
4063
+ bridge.handleBrowserMessage(browser, JSON.stringify({
4064
+ type: "mcp_set_servers",
4065
+ servers,
4066
+ }));
4067
+
4068
+ expect(cli.send).toHaveBeenCalledTimes(1);
4069
+ const sentRaw = cli.send.mock.calls[0][0] as string;
4070
+ const sent = JSON.parse(sentRaw.trim());
4071
+ expect(sent.type).toBe("control_request");
4072
+ expect(sent.request.subtype).toBe("mcp_set_servers");
4073
+ expect(sent.request.servers).toEqual(servers);
4074
+ vi.useRealTimers();
4075
+ });
4076
+ });
4077
+
4078
+ // ─── Per-session listener error handling ────────────────────────────────────
4079
+
4080
+ describe("per-session listener error handling", () => {
4081
+ it("catches and logs errors thrown by assistant message listeners", async () => {
4082
+ // A throwing listener registered on the event bus should not crash
4083
+ // the message pipeline or prevent persistSession from running.
4084
+ // The EventBus catches handler errors and logs them.
4085
+ const sessionId = "listener-error-session";
4086
+ const cli = makeCliSocket(sessionId);
4087
+ bridge.handleCLIOpen(cli, sessionId);
4088
+ await bridge.handleCLIMessage(cli, makeInitMsg());
4089
+
4090
+ const browser = makeBrowserSocket(sessionId);
4091
+ bridge.handleBrowserOpen(browser, sessionId);
4092
+
4093
+ // Register a throwing listener via the event bus
4094
+ const throwingCb = () => { throw new Error("listener boom"); };
4095
+ companionBus.on("message:assistant", ({ sessionId: sid, message }) => {
4096
+ if (sid === sessionId) throwingCb();
4097
+ });
4098
+
4099
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
4100
+
4101
+ // Send an assistant message — should not throw
4102
+ const assistantMsg = JSON.stringify({
4103
+ type: "assistant",
4104
+ message: { id: "m1", type: "message", role: "assistant", content: [{ type: "text", text: "hi" }], model: "test", stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } },
4105
+ });
4106
+ await bridge.handleCLIMessage(cli, assistantMsg);
4107
+
4108
+ expect(spy).toHaveBeenCalledWith(
4109
+ expect.stringContaining("Handler error"),
4110
+ expect.any(Error),
4111
+ );
4112
+
4113
+ spy.mockRestore();
4114
+ });
4115
+
4116
+ it("catches and logs errors from async result listeners", async () => {
4117
+ // A sync-throwing result listener registered on the event bus should
4118
+ // have its error caught and logged, not become an unhandled exception.
4119
+ const sessionId = "async-listener-session";
4120
+ const cli = makeCliSocket(sessionId);
4121
+ bridge.handleCLIOpen(cli, sessionId);
4122
+ await bridge.handleCLIMessage(cli, makeInitMsg());
4123
+
4124
+ const browser = makeBrowserSocket(sessionId);
4125
+ bridge.handleBrowserOpen(browser, sessionId);
4126
+
4127
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
4128
+
4129
+ // Register a sync-throwing listener for result via the event bus
4130
+ const throwingCb = () => { throw new Error("result listener boom"); };
4131
+ companionBus.on("message:result", ({ sessionId: sid, message }) => {
4132
+ if (sid === sessionId) throwingCb();
4133
+ });
4134
+
4135
+ // Send a result message
4136
+ const resultMsg = JSON.stringify({
4137
+ type: "result",
4138
+ data: { subtype: "success" },
4139
+ total_cost_usd: 0.01,
4140
+ num_turns: 1,
4141
+ is_error: false,
4142
+ });
4143
+ await bridge.handleCLIMessage(cli, resultMsg);
4144
+
4145
+ expect(spy).toHaveBeenCalledWith(
4146
+ expect.stringContaining("Handler error"),
4147
+ expect.any(Error),
4148
+ );
4149
+
4150
+ spy.mockRestore();
4151
+ });
4152
+
4153
+ it("catches and logs errors thrown by stream event listeners", async () => {
4154
+ const sessionId = "stream-listener-error-session";
4155
+ const cli = makeCliSocket(sessionId);
4156
+ bridge.handleCLIOpen(cli, sessionId);
4157
+ await bridge.handleCLIMessage(cli, makeInitMsg());
4158
+
4159
+ const browser = makeBrowserSocket(sessionId);
4160
+ bridge.handleBrowserOpen(browser, sessionId);
4161
+
4162
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
4163
+
4164
+ companionBus.on("message:stream_event", ({ sessionId: sid }) => {
4165
+ if (sid === sessionId) {
4166
+ throw new Error("stream listener boom");
4167
+ }
4168
+ });
4169
+
4170
+ await bridge.handleCLIMessage(cli, JSON.stringify({
4171
+ type: "stream_event",
4172
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "hi" } },
4173
+ parent_tool_use_id: null,
4174
+ uuid: "stream-listener-uuid-1",
4175
+ session_id: sessionId,
4176
+ }));
4177
+
4178
+ expect(spy).toHaveBeenCalledWith(
4179
+ expect.stringContaining("Handler error"),
4180
+ expect.any(Error),
4181
+ );
4182
+
4183
+ spy.mockRestore();
4184
+ });
4185
+ });
4186
+
4187
+ // ─── sendToCLI error handling ──────────────────────────────────────────────
4188
+
4189
+ describe("sendToCLI error path", () => {
4190
+ it("logs error when CLI socket send throws", async () => {
4191
+ // When the CLI socket's send() throws (e.g. socket already closed),
4192
+ // sendToCLI should catch the error and log it rather than crashing.
4193
+ const sessionId = "send-error-session";
4194
+
4195
+ const cli = makeCliSocket(sessionId);
4196
+ bridge.handleCLIOpen(cli, sessionId);
4197
+
4198
+ // Send a system.init to fully connect the session
4199
+ const initMsg = makeInitMsg();
4200
+ await bridge.handleCLIMessage(cli, initMsg);
4201
+
4202
+ // Now make send() throw to simulate a broken socket
4203
+ cli.send.mockImplementation(() => {
4204
+ throw new Error("Socket is closed");
4205
+ });
4206
+
4207
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
4208
+
4209
+ // Inject a user message which calls sendToCLI internally
4210
+ bridge.injectUserMessage(sessionId, "test message");
4211
+
4212
+ // The error should be caught and logged, not thrown
4213
+ expect(spy).toHaveBeenCalledWith(
4214
+ expect.stringContaining("Failed to send to CLI"),
4215
+ expect.any(Error),
4216
+ );
4217
+
4218
+ spy.mockRestore();
4219
+ });
4220
+ });
4221
+
4222
+ // ─── CLI message deduplication (Bun.hash-based) ─────────────────────────────
4223
+
4224
+ describe("CLI message deduplication", () => {
4225
+ async function setupSession() {
4226
+ const cli = makeCliSocket("s1");
4227
+ const browser = makeBrowserSocket("s1");
4228
+ bridge.handleBrowserOpen(browser, "s1");
4229
+ bridge.handleCLIOpen(cli, "s1");
4230
+ await bridge.handleCLIMessage(cli, makeInitMsg());
4231
+ browser.send.mockClear();
4232
+ return { cli, browser };
4233
+ }
4234
+
4235
+ it("filters duplicate assistant messages (same content replayed on reconnect)", async () => {
4236
+ const { cli, browser } = await setupSession();
4237
+ const msg = JSON.stringify({ type: "assistant", message: { content: "hello world" } });
4238
+
4239
+ // First send — should forward to browser
4240
+ await bridge.handleCLIMessage(cli, msg);
4241
+ expect(browser.send).toHaveBeenCalledTimes(1);
4242
+
4243
+ // Same message again (simulates CLI replay on WS reconnect) — should be filtered
4244
+ browser.send.mockClear();
4245
+ await bridge.handleCLIMessage(cli, msg);
4246
+ expect(browser.send).not.toHaveBeenCalled();
4247
+ });
4248
+
4249
+ it("forwards non-duplicate assistant messages normally", async () => {
4250
+ const { cli, browser } = await setupSession();
4251
+ const msg1 = JSON.stringify({ type: "assistant", message: { content: "first" } });
4252
+ const msg2 = JSON.stringify({ type: "assistant", message: { content: "second" } });
4253
+
4254
+ await bridge.handleCLIMessage(cli, msg1);
4255
+ await bridge.handleCLIMessage(cli, msg2);
4256
+
4257
+ expect(browser.send).toHaveBeenCalledTimes(2);
4258
+ });
4259
+
4260
+ it("evicts oldest hashes when window is exceeded", async () => {
4261
+ const { cli, browser } = await setupSession();
4262
+
4263
+ // Send CLI_DEDUP_WINDOW + 1 unique messages to push the first one out
4264
+ const WINDOW = 2000; // matches WsBridge.CLI_DEDUP_WINDOW
4265
+ for (let i = 0; i <= WINDOW; i++) {
4266
+ await bridge.handleCLIMessage(
4267
+ cli,
4268
+ JSON.stringify({ type: "assistant", message: { content: `msg-${i}` } }),
4269
+ );
4270
+ }
4271
+
4272
+ // The first message's hash should have been evicted — resending it should work
4273
+ browser.send.mockClear();
4274
+ const firstMsg = JSON.stringify({ type: "assistant", message: { content: "msg-0" } });
4275
+ await bridge.handleCLIMessage(cli, firstMsg);
4276
+ expect(browser.send).toHaveBeenCalledTimes(1);
4277
+ });
4278
+
4279
+ it("deduplicates stream_event messages with the same uuid on reconnect replay", async () => {
4280
+ const { cli, browser } = await setupSession();
4281
+ const uuid = "cc6aeb12-1aad-4126-8ad2-03bad206e9fe";
4282
+ const msg = JSON.stringify({
4283
+ type: "stream_event",
4284
+ event: { type: "content_block_delta", delta: { type: "thinking_delta", text: "thinking..." } },
4285
+ parent_tool_use_id: null,
4286
+ uuid,
4287
+ session_id: "test-cli-session",
4288
+ });
4289
+
4290
+ // First send — should forward to browser
4291
+ await bridge.handleCLIMessage(cli, msg);
4292
+ expect(browser.send).toHaveBeenCalledTimes(1);
4293
+
4294
+ // Same uuid again (simulates CLI replay on WS reconnect) — should be filtered
4295
+ browser.send.mockClear();
4296
+ await bridge.handleCLIMessage(cli, msg);
4297
+ expect(browser.send).not.toHaveBeenCalled();
4298
+ });
4299
+
4300
+ it("forwards stream_event messages without uuid (no dedup possible)", async () => {
4301
+ const { cli, browser } = await setupSession();
4302
+ // stream_event without uuid — cannot dedup, must forward
4303
+ const msg = JSON.stringify({
4304
+ type: "stream_event",
4305
+ event: { type: "content_block_delta", delta: { type: "text_delta", text: "hi" } },
4306
+ parent_tool_use_id: null,
4307
+ });
4308
+
4309
+ await bridge.handleCLIMessage(cli, msg);
4310
+ await bridge.handleCLIMessage(cli, msg);
4311
+
4312
+ // Both should be forwarded — no uuid means no dedup
4313
+ expect(browser.send).toHaveBeenCalledTimes(2);
4314
+ });
4315
+ });
4316
+
4317
+ // ─── Linear session ID mapping ──────────────────────────────────────────────
4318
+
4319
+ describe("Linear session ID mapping", () => {
4320
+ it("setLinearSessionId sets linearSessionId on session state", () => {
4321
+ // Create a session via getOrCreateSession, then call setLinearSessionId
4322
+ // and verify the linearSessionId is persisted on the session state.
4323
+ bridge.getOrCreateSession("s1");
4324
+ const saveSpy = vi.spyOn(store, "save");
4325
+
4326
+ bridge.setLinearSessionId("s1", "linear-abc-123");
4327
+
4328
+ const session = bridge.getSession("s1")!;
4329
+ expect(session.state.linearSessionId).toBe("linear-abc-123");
4330
+
4331
+ // Verify persistSession was called (via store.save) to persist the change
4332
+ expect(saveSpy).toHaveBeenCalled();
4333
+ const lastCall = saveSpy.mock.calls[saveSpy.mock.calls.length - 1][0];
4334
+ expect(lastCall.id).toBe("s1");
4335
+ expect(lastCall.state.linearSessionId).toBe("linear-abc-123");
4336
+ });
4337
+
4338
+ it("setLinearSessionId is a no-op when session does not exist", () => {
4339
+ // Calling setLinearSessionId with a non-existent sessionId should not
4340
+ // throw an error and should not create a new session.
4341
+ const saveSpy = vi.spyOn(store, "save");
4342
+
4343
+ expect(() => {
4344
+ bridge.setLinearSessionId("nonexistent-session", "linear-xyz");
4345
+ }).not.toThrow();
4346
+
4347
+ // No session should have been created
4348
+ expect(bridge.getSession("nonexistent-session")).toBeUndefined();
4349
+
4350
+ // persistSession should NOT have been called since the session doesn't exist
4351
+ expect(saveSpy).not.toHaveBeenCalled();
4352
+ });
4353
+
4354
+ it("getLinearSessionMappings returns sessions with linearSessionId", () => {
4355
+ // Create multiple sessions, set linearSessionId on some of them,
4356
+ // and verify only the sessions with a linearSessionId are returned.
4357
+ bridge.getOrCreateSession("s1");
4358
+ bridge.getOrCreateSession("s2");
4359
+ bridge.getOrCreateSession("s3");
4360
+
4361
+ bridge.setLinearSessionId("s1", "linear-aaa");
4362
+ bridge.setLinearSessionId("s3", "linear-ccc");
4363
+ // s2 intentionally left without a linearSessionId
4364
+
4365
+ const mappings = bridge.getLinearSessionMappings();
4366
+
4367
+ expect(mappings).toHaveLength(2);
4368
+ expect(mappings).toEqual(
4369
+ expect.arrayContaining([
4370
+ { sessionId: "s1", linearSessionId: "linear-aaa" },
4371
+ { sessionId: "s3", linearSessionId: "linear-ccc" },
4372
+ ]),
4373
+ );
4374
+
4375
+ // Verify s2 (which has no linearSessionId) is NOT included
4376
+ const s2Mapping = mappings.find((m) => m.sessionId === "s2");
4377
+ expect(s2Mapping).toBeUndefined();
4378
+ });
4379
+
4380
+ it("getLinearSessionMappings returns empty array when no sessions have linearSessionId", () => {
4381
+ // Create sessions without setting any linearSessionId and verify
4382
+ // the method returns an empty array.
4383
+ bridge.getOrCreateSession("s1");
4384
+ bridge.getOrCreateSession("s2");
4385
+
4386
+ const mappings = bridge.getLinearSessionMappings();
4387
+
4388
+ expect(mappings).toEqual([]);
4389
+ });
4390
+ });
4391
+
4392
+ // ─── Callback registration coverage ────────────────────────────────────────────
4393
+
4394
+ describe("diagnostics and callbacks", () => {
4395
+ it("getSessionMemoryStats returns memory stats for all sessions", () => {
4396
+ bridge.getOrCreateSession("diag-1");
4397
+ bridge.getOrCreateSession("diag-2");
4398
+
4399
+ const stats = bridge.getSessionMemoryStats();
4400
+ expect(stats).toHaveLength(2);
4401
+ expect(stats[0].id).toBe("diag-1");
4402
+ expect(stats[0].browsers).toBe(0);
4403
+ expect(stats[0].historyLen).toBe(0);
4404
+ expect(stats[1].id).toBe("diag-2");
4405
+ });
4406
+
4407
+ it("companionBus message:assistant: unsubscribe function removes the listener", async () => {
4408
+ // After event bus migration, per-session listeners are registered via
4409
+ // companionBus.on("message:assistant", ...) with a sessionId filter.
4410
+ const cli = makeCliSocket("s1");
4411
+ const browser = makeBrowserSocket("s1");
4412
+ bridge.handleCLIOpen(cli, "s1");
4413
+ bridge.handleBrowserOpen(browser, "s1");
4414
+
4415
+ const listener = vi.fn();
4416
+ const unsubscribe = companionBus.on("message:assistant", ({ sessionId, message }) => {
4417
+ if (sessionId === "s1") listener(message);
4418
+ });
4419
+
4420
+ // Send an assistant message — listener should fire
4421
+ await bridge.handleCLIMessage(cli, JSON.stringify({
4422
+ type: "assistant",
4423
+ message: { id: "m1", type: "message", role: "assistant", model: "claude", content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } },
4424
+ parent_tool_use_id: null,
4425
+ uuid: "uuid-unsub-1",
4426
+ session_id: "s1",
4427
+ }));
4428
+ expect(listener).toHaveBeenCalledTimes(1);
4429
+
4430
+ // Unsubscribe and send another — listener should NOT fire again
4431
+ unsubscribe();
4432
+ await bridge.handleCLIMessage(cli, JSON.stringify({
4433
+ type: "assistant",
4434
+ message: { id: "m2", type: "message", role: "assistant", model: "claude", content: [], stop_reason: "end_turn", usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } },
4435
+ parent_tool_use_id: null,
4436
+ uuid: "uuid-unsub-2",
4437
+ session_id: "s1",
4438
+ }));
4439
+ expect(listener).toHaveBeenCalledTimes(1); // Still 1 — unsubscribed
4440
+ });
4441
+
4442
+ it("companionBus message:result: unsubscribe function removes the listener", async () => {
4443
+ // After event bus migration, per-session listeners are registered via
4444
+ // companionBus.on("message:result", ...) with a sessionId filter.
4445
+ const cli = makeCliSocket("s1");
4446
+ const browser = makeBrowserSocket("s1");
4447
+ bridge.handleCLIOpen(cli, "s1");
4448
+ bridge.handleBrowserOpen(browser, "s1");
4449
+
4450
+ // First send a user message so onFirstTurnCompleted logic works
4451
+ bridge.handleBrowserMessage(browser, JSON.stringify({
4452
+ type: "user_message", content: "test",
4453
+ }));
4454
+
4455
+ const listener = vi.fn();
4456
+ const unsubscribe = companionBus.on("message:result", ({ sessionId, message }) => {
4457
+ if (sessionId === "s1") listener(message);
4458
+ });
4459
+
4460
+ // Send a result message — listener should fire
4461
+ await bridge.handleCLIMessage(cli, JSON.stringify({
4462
+ type: "result", subtype: "success", is_error: false,
4463
+ duration_ms: 100, duration_api_ms: 50, num_turns: 1,
4464
+ total_cost_usd: 0.01, stop_reason: "end_turn",
4465
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
4466
+ uuid: "uuid-result-unsub-1", session_id: "s1",
4467
+ }));
4468
+ expect(listener).toHaveBeenCalledTimes(1);
4469
+
4470
+ // Unsubscribe and send another — listener should NOT fire again
4471
+ unsubscribe();
4472
+ await bridge.handleCLIMessage(cli, JSON.stringify({
4473
+ type: "result", subtype: "success", is_error: false,
4474
+ duration_ms: 200, duration_api_ms: 100, num_turns: 2,
4475
+ total_cost_usd: 0.02, stop_reason: "end_turn",
4476
+ usage: { input_tokens: 20, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
4477
+ uuid: "uuid-result-unsub-2", session_id: "s1",
4478
+ }));
4479
+ expect(listener).toHaveBeenCalledTimes(1); // Still 1 — unsubscribed
4480
+ });
4481
+
4482
+ it("getCodexRateLimits returns null for unknown session", () => {
4483
+ // Covers the early-return path when session doesn't exist.
4484
+ expect(bridge.getCodexRateLimits("nonexistent")).toBeNull();
4485
+ });
4486
+
4487
+ it("getCodexRateLimits returns null when no codex adapter", () => {
4488
+ // Covers the path where session exists but has no codex adapter.
4489
+ bridge.getOrCreateSession("no-adapter");
4490
+ expect(bridge.getCodexRateLimits("no-adapter")).toBeNull();
4491
+ });
4492
+
4493
+ it("broadcastToSession is a no-op for unknown session", () => {
4494
+ // Covers the early-return path when session doesn't exist.
4495
+ expect(() => bridge.broadcastToSession("nonexistent", { type: "cli_connected" })).not.toThrow();
4496
+ });
4497
+
4498
+ it("broadcastToSession sends to connected browsers", () => {
4499
+ // Covers the happy path: session exists and has browsers.
4500
+ const browser = makeBrowserSocket("bcast");
4501
+ bridge.getOrCreateSession("bcast");
4502
+ bridge.handleBrowserOpen(browser, "bcast");
4503
+ bridge.broadcastToSession("bcast", { type: "cli_connected" });
4504
+ expect(browser.send).toHaveBeenCalled();
4505
+ });
4506
+
4507
+ it("setRecorder stores the recorder reference", () => {
4508
+ // Covers the setRecorder setter (line 165).
4509
+ const fakeRecorder = { start: vi.fn(), stop: vi.fn() } as any;
4510
+ bridge.setRecorder(fakeRecorder);
4511
+ expect((bridge as any).recorder).toBe(fakeRecorder);
4512
+ });
4513
+ });
4514
+
4515
+ // ─── set_ai_validation browser message ──────────────────────────────────────
4516
+
4517
+ describe("set_ai_validation browser message", () => {
4518
+ it("updates AI validation settings and broadcasts session_update", () => {
4519
+ // When a browser sends set_ai_validation, the bridge should update the
4520
+ // session state and broadcast the new settings to all connected browsers.
4521
+ const browser = makeBrowserSocket("s1");
4522
+ bridge.handleBrowserOpen(browser, "s1");
4523
+ browser.send.mockClear();
4524
+
4525
+ bridge.handleBrowserMessage(
4526
+ browser,
4527
+ JSON.stringify({
4528
+ type: "set_ai_validation",
4529
+ aiValidationEnabled: true,
4530
+ aiValidationAutoApprove: true,
4531
+ aiValidationAutoDeny: false,
4532
+ }),
4533
+ );
4534
+
4535
+ const session = bridge.getSession("s1")!;
4536
+ expect(session.state.aiValidationEnabled).toBe(true);
4537
+ expect(session.state.aiValidationAutoApprove).toBe(true);
4538
+ expect(session.state.aiValidationAutoDeny).toBe(false);
4539
+
4540
+ // Should have broadcast session_update with the new AI validation settings
4541
+ const calls = browser.send.mock.calls.map(([arg]: [string]) => JSON.parse(arg));
4542
+ const updateMsg = calls.find((c: any) => c.type === "session_update");
4543
+ expect(updateMsg).toBeDefined();
4544
+ expect(updateMsg.session.aiValidationEnabled).toBe(true);
4545
+ expect(updateMsg.session.aiValidationAutoApprove).toBe(true);
4546
+ expect(updateMsg.session.aiValidationAutoDeny).toBe(false);
4547
+ });
4548
+
4549
+ it("does not forward set_ai_validation to CLI backend", () => {
4550
+ // set_ai_validation is a bridge-level message that should never be
4551
+ // sent to the CLI. Verify the CLI socket does not receive it.
4552
+ const cli = makeCliSocket("s1");
4553
+ const browser = makeBrowserSocket("s1");
4554
+ bridge.handleCLIOpen(cli, "s1");
4555
+ bridge.handleBrowserOpen(browser, "s1");
4556
+ cli.send.mockClear();
4557
+
4558
+ bridge.handleBrowserMessage(
4559
+ browser,
4560
+ JSON.stringify({
4561
+ type: "set_ai_validation",
4562
+ aiValidationEnabled: true,
4563
+ }),
4564
+ );
4565
+
4566
+ // CLI should not have received any messages after clearing
4567
+ const cliCalls = cli.send.mock.calls.map(([arg]: [string]) => arg);
4568
+ const aiMsg = cliCalls.find((s: string) => s.includes("set_ai_validation"));
4569
+ expect(aiMsg).toBeUndefined();
4570
+ });
4571
+ });
4572
+
4573
+ // ─── Idle kill watchdog ─────────────────────────────────────────────────────
4574
+
4575
+ describe("Idle kill watchdog", () => {
4576
+ beforeEach(() => {
4577
+ vi.useFakeTimers();
4578
+ });
4579
+
4580
+ afterEach(() => {
4581
+ vi.useRealTimers();
4582
+ });
4583
+
4584
+ it("starts watchdog when last browser disconnects and emits idle-kill after threshold", () => {
4585
+ // When the last browser disconnects, the bridge should start a periodic
4586
+ // idle check. If no CLI activity occurs for IDLE_KILL_THRESHOLD_MS and
4587
+ // no browser reconnects, the session:idle-kill event should fire.
4588
+ const idleKillHandler = vi.fn();
4589
+ companionBus.on("session:idle-kill", idleKillHandler);
4590
+
4591
+ const cli = makeCliSocket("s1");
4592
+ const browser = makeBrowserSocket("s1");
4593
+ bridge.handleCLIOpen(cli, "s1");
4594
+ bridge.handleBrowserOpen(browser, "s1");
4595
+
4596
+ // Disconnect the browser — should start idle watchdog
4597
+ bridge.handleBrowserClose(browser);
4598
+
4599
+ // Advance past the idle kill threshold (default 24h) + check interval (60s)
4600
+ // The watchdog checks every 60s, so we need to advance enough for:
4601
+ // 1) The idle threshold to be exceeded (24h)
4602
+ // 2) A check interval to fire
4603
+ vi.advanceTimersByTime(24 * 60 * 60_000 + 60_000);
4604
+
4605
+ expect(idleKillHandler).toHaveBeenCalledWith({ sessionId: "s1" });
4606
+ });
4607
+
4608
+ it("cancels watchdog when browser reconnects before idle threshold", () => {
4609
+ // If a browser reconnects before the idle threshold, the watchdog
4610
+ // should be cancelled and no idle-kill event should fire.
4611
+ const idleKillHandler = vi.fn();
4612
+ companionBus.on("session:idle-kill", idleKillHandler);
4613
+
4614
+ const cli = makeCliSocket("s1");
4615
+ const browser1 = makeBrowserSocket("s1");
4616
+ bridge.handleCLIOpen(cli, "s1");
4617
+ bridge.handleBrowserOpen(browser1, "s1");
4618
+
4619
+ // Disconnect browser — starts watchdog
4620
+ bridge.handleBrowserClose(browser1);
4621
+
4622
+ // Advance a bit (5 min) but not past threshold
4623
+ vi.advanceTimersByTime(5 * 60_000);
4624
+
4625
+ // Reconnect a browser — should cancel watchdog
4626
+ const browser2 = makeBrowserSocket("s1");
4627
+ bridge.handleBrowserOpen(browser2, "s1");
4628
+
4629
+ // Advance well past the 24h threshold
4630
+ vi.advanceTimersByTime(25 * 60 * 60_000);
4631
+
4632
+ // Should NOT have triggered idle kill
4633
+ expect(idleKillHandler).not.toHaveBeenCalled();
4634
+ });
4635
+
4636
+ it("checkIdleKill stops watchdog if session is removed", () => {
4637
+ // If the session is removed while the watchdog is running (e.g. user
4638
+ // deleted it), the watchdog should clean itself up on the next tick.
4639
+ const idleKillHandler = vi.fn();
4640
+ companionBus.on("session:idle-kill", idleKillHandler);
4641
+
4642
+ const cli = makeCliSocket("s1");
4643
+ const browser = makeBrowserSocket("s1");
4644
+ bridge.handleCLIOpen(cli, "s1");
4645
+ bridge.handleBrowserOpen(browser, "s1");
4646
+
4647
+ // Disconnect browser — starts watchdog
4648
+ bridge.handleBrowserClose(browser);
4649
+
4650
+ // Remove session while watchdog is active
4651
+ bridge.removeSession("s1");
4652
+
4653
+ // Advance past 24h threshold + check interval
4654
+ vi.advanceTimersByTime(24 * 60 * 60_000 + 60_000);
4655
+
4656
+ // Should NOT fire idle-kill because session was removed
4657
+ expect(idleKillHandler).not.toHaveBeenCalled();
4658
+ });
4659
+
4660
+ it("checkIdleKill stops watchdog if browser reconnects before check fires", () => {
4661
+ // Edge case: browser reconnects between check intervals. The next
4662
+ // check should see browserSockets.size > 0 and cancel the watchdog.
4663
+ const idleKillHandler = vi.fn();
4664
+ companionBus.on("session:idle-kill", idleKillHandler);
4665
+
4666
+ const cli = makeCliSocket("s1");
4667
+ const browser1 = makeBrowserSocket("s1");
4668
+ bridge.handleCLIOpen(cli, "s1");
4669
+ bridge.handleBrowserOpen(browser1, "s1");
4670
+
4671
+ // Disconnect browser
4672
+ bridge.handleBrowserClose(browser1);
4673
+
4674
+ // Advance 10 min (past one check interval but under threshold)
4675
+ vi.advanceTimersByTime(10 * 60_000);
4676
+
4677
+ // Manually add a browser socket directly to simulate reconnect
4678
+ // without calling handleBrowserOpen (which would cancel watchdog)
4679
+ const session = bridge.getSession("s1")!;
4680
+ const browser2 = makeBrowserSocket("s1");
4681
+ session.browserSockets.add(browser2);
4682
+
4683
+ // Advance past 24h threshold
4684
+ vi.advanceTimersByTime(24 * 60 * 60_000);
4685
+
4686
+ // Watchdog should have noticed the browser and cancelled itself
4687
+ expect(idleKillHandler).not.toHaveBeenCalled();
4688
+ });
4689
+ });
4690
+
4691
+ // ─── injectMcpSetServers ────────────────────────────────────────────────────
4692
+
4693
+ describe("injectMcpSetServers", () => {
4694
+ it("sends mcp_set_servers to backend adapter", () => {
4695
+ // When injectMcpSetServers is called on a connected session, it should
4696
+ // forward the MCP server configuration to the backend adapter.
4697
+ const cli = makeCliSocket("s1");
4698
+ bridge.handleCLIOpen(cli, "s1");
4699
+
4700
+ const servers = { "test-mcp": { command: "test-cmd", args: [] } } as any;
4701
+ bridge.injectMcpSetServers("s1", servers);
4702
+
4703
+ // The CLI socket should have received the mcp_set_servers message
4704
+ const calls = cli.send.mock.calls.map(([arg]: [string]) => arg);
4705
+ const mcpMsg = calls.find((s: string) => s.includes("mcp_set_servers"));
4706
+ expect(mcpMsg).toBeDefined();
4707
+ });
4708
+
4709
+ it("is a no-op for nonexistent session", () => {
4710
+ // Should log an error but not throw.
4711
+ expect(() => bridge.injectMcpSetServers("nonexistent", {})).not.toThrow();
4712
+ });
4713
+ });
4714
+
4715
+ // ─── injectSystemPrompt ─────────────────────────────────────────────────────
4716
+
4717
+ describe("injectSystemPrompt", () => {
4718
+ it("sends initialize control_request to ClaudeAdapter", () => {
4719
+ // When injectSystemPrompt is called on a Claude session, it should
4720
+ // send a raw NDJSON control_request with the appendSystemPrompt.
4721
+ const cli = makeCliSocket("s1");
4722
+ bridge.handleCLIOpen(cli, "s1");
4723
+
4724
+ bridge.injectSystemPrompt("s1", "You are a helpful assistant.");
4725
+
4726
+ // The CLI socket should have received the control_request
4727
+ const calls = cli.send.mock.calls.map(([arg]: [string]) => arg);
4728
+ const initMsg = calls.find((s: string) => s.includes("appendSystemPrompt"));
4729
+ expect(initMsg).toBeDefined();
4730
+ const parsed = JSON.parse(initMsg!.trim());
4731
+ expect(parsed.type).toBe("control_request");
4732
+ expect(parsed.request.subtype).toBe("initialize");
4733
+ expect(parsed.request.appendSystemPrompt).toBe("You are a helpful assistant.");
4734
+ });
4735
+
4736
+ it("is a no-op for nonexistent session", () => {
4737
+ // Should log an error but not throw.
4738
+ expect(() => bridge.injectSystemPrompt("nonexistent", "prompt")).not.toThrow();
4739
+ });
4740
+ });
4741
+
4742
+ // ─── User message during initialization ──────────────────────────────────────
4743
+
4744
+ describe("User message during initializing phase", () => {
4745
+ it("transitions to streaming and forwards user_message when session is initializing", () => {
4746
+ // Simulate a session where the CLI socket has connected (initializing)
4747
+ // but the system.init message hasn't arrived yet (so not "ready").
4748
+ // The message should still be forwarded to the adapter's internal queue
4749
+ // rather than being dropped, so the user doesn't have to resend.
4750
+ const cli = makeCliSocket("s1");
4751
+ const browser = makeBrowserSocket("s1");
4752
+ bridge.handleCLIOpen(cli, "s1");
4753
+ bridge.handleBrowserOpen(browser, "s1");
4754
+
4755
+ // Session should be in "initializing" phase after CLI connects
4756
+ const session = bridge.getSession("s1")!;
4757
+ expect(session.stateMachine.phase).toBe("initializing");
4758
+
4759
+ // Send a user message while still initializing
4760
+ cli.send.mockClear();
4761
+ bridge.handleBrowserMessage(browser, JSON.stringify({
4762
+ type: "user_message",
4763
+ content: "Hello while initializing",
4764
+ }));
4765
+
4766
+ // The message IS forwarded to the CLI adapter (which queues internally)
4767
+ expect(cli.send).toHaveBeenCalledTimes(1);
4768
+
4769
+ // The message should be in the history (user typed it)
4770
+ const userMsgs = session.messageHistory.filter((m) => m.type === "user_message");
4771
+ expect(userMsgs.length).toBe(1);
4772
+
4773
+ // State machine transitions to streaming — the adapter queues the
4774
+ // message internally until the backend is ready.
4775
+ expect(session.stateMachine.phase).toBe("streaming");
4776
+ });
4777
+ });