@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,1837 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Mock settings-manager before importing the module under test
4
+ vi.mock("./settings-manager.js", () => ({
5
+ DEFAULT_ANTHROPIC_MODEL: "claude-sonnet-4-6",
6
+ getSettings: vi.fn(),
7
+ }));
8
+
9
+ // Mock ai-validator before importing the module under test
10
+ vi.mock("./ai-validator.js", () => ({
11
+ validatePermission: vi.fn(),
12
+ }));
13
+
14
+ import { attachCodexAdapterHandlers } from "./ws-bridge-codex.js";
15
+ import type { BrowserIncomingMessage, SessionState } from "./session-types.js";
16
+ import type { Session } from "./ws-bridge-types.js";
17
+ import type { CodexAdapter } from "./codex-adapter.js";
18
+ import type { CodexAttachDeps } from "./ws-bridge-codex.js";
19
+ import * as settingsManager from "./settings-manager.js";
20
+ import * as aiValidator from "./ai-validator.js";
21
+ import { companionBus } from "./event-bus.js";
22
+ import { SessionStateMachine } from "./session-state-machine.js";
23
+
24
+ // ── Mock Factories ──────────────────────────────────────────────────────────
25
+
26
+ function createMockSession(overrides = {}): Session {
27
+ return {
28
+ id: "test-session",
29
+ backendType: "codex",
30
+ backendAdapter: null,
31
+ browserSockets: new Set(),
32
+ state: {
33
+ session_id: "test-session",
34
+ backend_type: "codex",
35
+ model: "",
36
+ cwd: "",
37
+ tools: [],
38
+ permissionMode: "default",
39
+ claude_code_version: "",
40
+ mcp_servers: [],
41
+ agents: [],
42
+ slash_commands: [],
43
+ skills: [],
44
+ total_cost_usd: 0,
45
+ num_turns: 0,
46
+ context_used_percent: 0,
47
+ is_compacting: false,
48
+ git_branch: "",
49
+ is_worktree: false,
50
+ is_containerized: false,
51
+ repo_root: "",
52
+ git_ahead: 0,
53
+ git_behind: 0,
54
+ total_lines_added: 0,
55
+ total_lines_removed: 0,
56
+ } as SessionState,
57
+ pendingPermissions: new Map(),
58
+ messageHistory: [] as BrowserIncomingMessage[],
59
+ pendingMessages: [] as string[],
60
+ nextEventSeq: 0,
61
+ eventBuffer: [],
62
+ lastAckSeq: 0,
63
+ processedClientMessageIds: [],
64
+ processedClientMessageIdSet: new Set(),
65
+ lastCliActivityTs: Date.now(),
66
+ stateMachine: new SessionStateMachine("test-session"),
67
+ ...overrides,
68
+ } as Session;
69
+ }
70
+
71
+ function createMockAdapter() {
72
+ const handlers: Record<string, Function> = {};
73
+ return {
74
+ onBrowserMessage: vi.fn((fn: Function) => {
75
+ handlers.onBrowserMessage = fn;
76
+ }),
77
+ onSessionMeta: vi.fn((fn: Function) => {
78
+ handlers.onSessionMeta = fn;
79
+ }),
80
+ onDisconnect: vi.fn((fn: Function) => {
81
+ handlers.onDisconnect = fn;
82
+ }),
83
+ sendBrowserMessage: vi.fn(),
84
+ /** Helper to trigger a registered handler in tests */
85
+ _trigger: (event: string, data: any) => handlers[event]?.(data),
86
+ };
87
+ }
88
+
89
+ function createMockDeps(overrides = {}): CodexAttachDeps {
90
+ return {
91
+ persistSession: vi.fn(),
92
+ refreshGitInfo: vi.fn(),
93
+ broadcastToBrowsers: vi.fn(),
94
+ autoNamingAttempted: new Set<string>(),
95
+ ...overrides,
96
+ };
97
+ }
98
+
99
+ // ── Tests ────────────────────────────────────────────────────────────────────
100
+
101
+ describe("attachCodexAdapterHandlers", () => {
102
+ let session: Session;
103
+ let adapter: ReturnType<typeof createMockAdapter>;
104
+ let deps: CodexAttachDeps;
105
+
106
+ beforeEach(() => {
107
+ vi.clearAllMocks();
108
+ companionBus.clear();
109
+ session = createMockSession();
110
+ adapter = createMockAdapter();
111
+ deps = createMockDeps();
112
+
113
+ // Default: AI validation disabled — existing tests should not be affected
114
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
115
+ anthropicApiKey: "",
116
+ anthropicModel: "claude-sonnet-4-6",
117
+ linearApiKey: "",
118
+ linearAutoTransition: false,
119
+ linearAutoTransitionStateId: "",
120
+ linearAutoTransitionStateName: "",
121
+ linearArchiveTransition: false,
122
+ linearArchiveTransitionStateId: "",
123
+ linearArchiveTransitionStateName: "",
124
+ linearOAuthClientId: "",
125
+ linearOAuthClientSecret: "",
126
+ linearOAuthWebhookSecret: "",
127
+ linearOAuthAccessToken: "",
128
+ linearOAuthRefreshToken: "",
129
+ claudeCodeOAuthToken: "",
130
+ openaiApiKey: "",
131
+ onboardingCompleted: false,
132
+ aiValidationEnabled: false,
133
+ aiValidationAutoApprove: true,
134
+ aiValidationAutoDeny: false,
135
+ publicUrl: "",
136
+ updateChannel: "stable",
137
+ dockerAutoUpdate: false,
138
+ updatedAt: 0,
139
+ });
140
+ });
141
+
142
+ // ── Handler registration ────────────────────────────────────────────────
143
+
144
+ it("registers onBrowserMessage, onSessionMeta, and onDisconnect handlers", () => {
145
+ // Verifies that attachCodexAdapterHandlers wires all three adapter callbacks.
146
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
147
+
148
+ expect(adapter.onBrowserMessage).toHaveBeenCalledOnce();
149
+ expect(adapter.onSessionMeta).toHaveBeenCalledOnce();
150
+ expect(adapter.onDisconnect).toHaveBeenCalledOnce();
151
+ });
152
+
153
+ // ── session_init ────────────────────────────────────────────────────────
154
+
155
+ it("session_init updates session state with backend_type and persists", () => {
156
+ // session_init should merge the incoming session state into session.state,
157
+ // set backend_type to "codex", call refreshGitInfo, and persist.
158
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
159
+
160
+ const sessionInitPayload: BrowserIncomingMessage = {
161
+ type: "session_init",
162
+ session: {
163
+ session_id: "test-session",
164
+ backend_type: "codex",
165
+ model: "o3-pro",
166
+ cwd: "/home/user/project",
167
+ tools: [],
168
+ permissionMode: "default",
169
+ claude_code_version: "",
170
+ mcp_servers: [],
171
+ agents: [],
172
+ slash_commands: [],
173
+ skills: [],
174
+ total_cost_usd: 0,
175
+ num_turns: 0,
176
+ context_used_percent: 0,
177
+ is_compacting: false,
178
+ git_branch: "",
179
+ is_worktree: false,
180
+ is_containerized: false,
181
+ repo_root: "",
182
+ git_ahead: 0,
183
+ git_behind: 0,
184
+ total_lines_added: 0,
185
+ total_lines_removed: 0,
186
+ },
187
+ };
188
+
189
+ adapter._trigger("onBrowserMessage", sessionInitPayload);
190
+
191
+ expect(session.state.model).toBe("o3-pro");
192
+ expect(session.state.cwd).toBe("/home/user/project");
193
+ expect(session.state.backend_type).toBe("codex");
194
+ expect(deps.refreshGitInfo).toHaveBeenCalledWith(session, { notifyPoller: true });
195
+ expect(deps.persistSession).toHaveBeenCalledWith(session);
196
+ });
197
+
198
+ it("session_init preserves pre-populated commands/skills when adapter sends empty arrays", () => {
199
+ // When prePopulateCommands has set commands/skills on the session before
200
+ // the Codex adapter sends session_init with empty arrays, the pre-populated
201
+ // data should be preserved (Codex does not provide its own commands/skills).
202
+ session.state.slash_commands = ["pre-cmd-1", "pre-cmd-2"];
203
+ session.state.skills = ["pre-skill"];
204
+
205
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
206
+
207
+ adapter._trigger("onBrowserMessage", {
208
+ type: "session_init",
209
+ session: {
210
+ session_id: "test-session",
211
+ backend_type: "codex",
212
+ model: "o3-pro",
213
+ cwd: "/home/user/project",
214
+ tools: [],
215
+ permissionMode: "default",
216
+ claude_code_version: "",
217
+ mcp_servers: [],
218
+ agents: [],
219
+ slash_commands: [],
220
+ skills: [],
221
+ total_cost_usd: 0,
222
+ num_turns: 0,
223
+ context_used_percent: 0,
224
+ is_compacting: false,
225
+ git_branch: "",
226
+ is_worktree: false,
227
+ is_containerized: false,
228
+ repo_root: "",
229
+ git_ahead: 0,
230
+ git_behind: 0,
231
+ total_lines_added: 0,
232
+ total_lines_removed: 0,
233
+ },
234
+ });
235
+
236
+ // Pre-populated data should survive the Codex session_init merge
237
+ expect(session.state.slash_commands).toEqual(["pre-cmd-1", "pre-cmd-2"]);
238
+ expect(session.state.skills).toEqual(["pre-skill"]);
239
+ // Other fields should still be updated
240
+ expect(session.state.model).toBe("o3-pro");
241
+ expect(session.state.cwd).toBe("/home/user/project");
242
+ });
243
+
244
+ it("session_init allows overwriting commands/skills when adapter sends non-empty arrays", () => {
245
+ // If a future Codex version sends actual commands/skills, they should
246
+ // overwrite the pre-populated data.
247
+ session.state.slash_commands = ["pre-cmd"];
248
+ session.state.skills = ["pre-skill"];
249
+
250
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
251
+
252
+ adapter._trigger("onBrowserMessage", {
253
+ type: "session_init",
254
+ session: {
255
+ session_id: "test-session",
256
+ backend_type: "codex",
257
+ model: "o3-pro",
258
+ cwd: "/home/user/project",
259
+ tools: [],
260
+ permissionMode: "default",
261
+ claude_code_version: "",
262
+ mcp_servers: [],
263
+ agents: [],
264
+ slash_commands: ["codex-cmd"],
265
+ skills: ["codex-skill"],
266
+ total_cost_usd: 0,
267
+ num_turns: 0,
268
+ context_used_percent: 0,
269
+ is_compacting: false,
270
+ git_branch: "",
271
+ is_worktree: false,
272
+ is_containerized: false,
273
+ repo_root: "",
274
+ git_ahead: 0,
275
+ git_behind: 0,
276
+ total_lines_added: 0,
277
+ total_lines_removed: 0,
278
+ },
279
+ });
280
+
281
+ // Non-empty arrays from the adapter should overwrite pre-populated data
282
+ expect(session.state.slash_commands).toEqual(["codex-cmd"]);
283
+ expect(session.state.skills).toEqual(["codex-skill"]);
284
+ });
285
+
286
+ // ── session_update ──────────────────────────────────────────────────────
287
+
288
+ it("session_update preserves pre-populated commands/skills when update has empty arrays", () => {
289
+ // Same preservation logic applies to session_update messages.
290
+ session.state.slash_commands = ["pre-cmd"];
291
+ session.state.skills = ["pre-skill"];
292
+
293
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
294
+
295
+ adapter._trigger("onBrowserMessage", {
296
+ type: "session_update",
297
+ session: { model: "gpt-4.1", slash_commands: [], skills: [] },
298
+ });
299
+
300
+ expect(session.state.slash_commands).toEqual(["pre-cmd"]);
301
+ expect(session.state.skills).toEqual(["pre-skill"]);
302
+ expect(session.state.model).toBe("gpt-4.1");
303
+ });
304
+
305
+ it("session_update merges partial state and sets backend_type to codex", () => {
306
+ // session_update should spread the partial session fields into state,
307
+ // force backend_type to "codex", refresh git info, and persist.
308
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
309
+
310
+ adapter._trigger("onBrowserMessage", {
311
+ type: "session_update",
312
+ session: { model: "gpt-4.1", permissionMode: "bypassPermissions" },
313
+ });
314
+
315
+ expect(session.state.model).toBe("gpt-4.1");
316
+ expect(session.state.permissionMode).toBe("bypassPermissions");
317
+ expect(session.state.backend_type).toBe("codex");
318
+ expect(deps.refreshGitInfo).toHaveBeenCalledWith(session, { notifyPoller: true });
319
+ expect(deps.persistSession).toHaveBeenCalled();
320
+ });
321
+
322
+ // ── status_change ───────────────────────────────────────────────────────
323
+
324
+ it("status_change sets is_compacting to true when status is 'compacting'", () => {
325
+ // When the adapter emits a status_change with status "compacting",
326
+ // the handler should set session.state.is_compacting = true.
327
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
328
+
329
+ adapter._trigger("onBrowserMessage", {
330
+ type: "status_change",
331
+ status: "compacting",
332
+ });
333
+
334
+ expect(session.state.is_compacting).toBe(true);
335
+ expect(deps.persistSession).toHaveBeenCalled();
336
+ });
337
+
338
+ it("status_change sets is_compacting to false when status is not 'compacting'", () => {
339
+ // When status is something other than "compacting" (e.g. null),
340
+ // is_compacting should be false.
341
+ session.state.is_compacting = true;
342
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
343
+
344
+ adapter._trigger("onBrowserMessage", {
345
+ type: "status_change",
346
+ status: null,
347
+ });
348
+
349
+ expect(session.state.is_compacting).toBe(false);
350
+ expect(deps.persistSession).toHaveBeenCalled();
351
+ });
352
+
353
+ // ── assistant message ───────────────────────────────────────────────────
354
+
355
+ it("assistant message is pushed to messageHistory with timestamp", () => {
356
+ // Assistant messages should be appended to the session's messageHistory
357
+ // array with a timestamp, and persisted.
358
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
359
+
360
+ const assistantMsg: BrowserIncomingMessage = {
361
+ type: "assistant",
362
+ message: {
363
+ id: "msg-1",
364
+ type: "message",
365
+ role: "assistant",
366
+ model: "o3-pro",
367
+ content: [{ type: "text", text: "Hello world" }],
368
+ stop_reason: "end_turn",
369
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
370
+ },
371
+ parent_tool_use_id: null,
372
+ timestamp: 1700000000000,
373
+ };
374
+
375
+ adapter._trigger("onBrowserMessage", assistantMsg);
376
+
377
+ expect(session.messageHistory).toHaveLength(1);
378
+ expect(session.messageHistory[0].type).toBe("assistant");
379
+ expect((session.messageHistory[0] as any).timestamp).toBe(1700000000000);
380
+ expect(deps.persistSession).toHaveBeenCalled();
381
+ });
382
+
383
+ it("assistant message gets a default timestamp when none is provided", () => {
384
+ // If the assistant message doesn't have a timestamp, it should use Date.now().
385
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
386
+
387
+ const beforeTime = Date.now();
388
+ adapter._trigger("onBrowserMessage", {
389
+ type: "assistant",
390
+ message: {
391
+ id: "msg-2",
392
+ type: "message",
393
+ role: "assistant",
394
+ model: "o3-pro",
395
+ content: [{ type: "text", text: "No timestamp" }],
396
+ stop_reason: "end_turn",
397
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
398
+ },
399
+ parent_tool_use_id: null,
400
+ });
401
+ const afterTime = Date.now();
402
+
403
+ const stored = session.messageHistory[0] as any;
404
+ expect(stored.timestamp).toBeGreaterThanOrEqual(beforeTime);
405
+ expect(stored.timestamp).toBeLessThanOrEqual(afterTime);
406
+ });
407
+
408
+ // ── result message ──────────────────────────────────────────────────────
409
+
410
+ it("result message is pushed to messageHistory", () => {
411
+ // Result messages should also be appended to messageHistory and persisted.
412
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
413
+
414
+ const resultMsg: BrowserIncomingMessage = {
415
+ type: "result",
416
+ data: {
417
+ type: "result",
418
+ subtype: "success",
419
+ is_error: false,
420
+ result: undefined,
421
+ duration_ms: 100,
422
+ duration_api_ms: 80,
423
+ num_turns: 1,
424
+ total_cost_usd: 0.01,
425
+ stop_reason: "end_turn",
426
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
427
+ uuid: "result-uuid-1",
428
+ session_id: "test-session",
429
+ },
430
+ };
431
+
432
+ adapter._trigger("onBrowserMessage", resultMsg);
433
+
434
+ expect(session.messageHistory).toHaveLength(1);
435
+ expect(session.messageHistory[0].type).toBe("result");
436
+ expect(deps.persistSession).toHaveBeenCalled();
437
+ });
438
+
439
+ // ── permission_request ──────────────────────────────────────────────────
440
+
441
+ it("permission_request is added to pendingPermissions", () => {
442
+ // When a permission_request comes in, it should be stored in the session's
443
+ // pendingPermissions map keyed by request_id, and persisted.
444
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
445
+
446
+ const permMsg: BrowserIncomingMessage = {
447
+ type: "permission_request",
448
+ request: {
449
+ request_id: "perm-1",
450
+ tool_name: "Bash",
451
+ input: { command: "ls -la" },
452
+ description: "Execute: ls -la",
453
+ tool_use_id: "tool-1",
454
+ timestamp: Date.now(),
455
+ },
456
+ };
457
+
458
+ adapter._trigger("onBrowserMessage", permMsg);
459
+
460
+ expect(session.pendingPermissions.has("perm-1")).toBe(true);
461
+ expect(session.pendingPermissions.get("perm-1")).toEqual(
462
+ expect.objectContaining({ request_id: "perm-1", tool_name: "Bash" }),
463
+ );
464
+ expect(deps.persistSession).toHaveBeenCalled();
465
+ });
466
+
467
+ it("permission_cancelled removes entry from pendingPermissions", () => {
468
+ // When the adapter emits permission_cancelled (e.g. after a WS reconnect),
469
+ // the bridge should remove the corresponding entry from pendingPermissions
470
+ // so the browser doesn't show a stale approval dialog.
471
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
472
+
473
+ // Pre-populate a pending permission
474
+ session.pendingPermissions.set("perm-stale", {
475
+ request_id: "perm-stale",
476
+ tool_name: "Bash",
477
+ input: { command: "rm -rf /" },
478
+ description: "Execute: rm -rf /",
479
+ tool_use_id: "tool-stale",
480
+ timestamp: Date.now(),
481
+ });
482
+
483
+ adapter._trigger("onBrowserMessage", {
484
+ type: "permission_cancelled",
485
+ request_id: "perm-stale",
486
+ });
487
+
488
+ expect(session.pendingPermissions.has("perm-stale")).toBe(false);
489
+ expect(deps.persistSession).toHaveBeenCalled();
490
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
491
+ session,
492
+ expect.objectContaining({ type: "permission_cancelled", request_id: "perm-stale" }),
493
+ );
494
+ });
495
+
496
+ // ── broadcast to browsers ───────────────────────────────────────────────
497
+
498
+ it("all messages are broadcast to browsers", () => {
499
+ // Every message that goes through onBrowserMessage should be broadcast
500
+ // to connected browser sockets.
501
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
502
+
503
+ const testMessages: BrowserIncomingMessage[] = [
504
+ {
505
+ type: "session_init",
506
+ session: session.state,
507
+ },
508
+ {
509
+ type: "session_update",
510
+ session: { model: "updated-model" },
511
+ },
512
+ {
513
+ type: "status_change",
514
+ status: "compacting",
515
+ },
516
+ {
517
+ type: "assistant",
518
+ message: {
519
+ id: "msg-1",
520
+ type: "message",
521
+ role: "assistant",
522
+ model: "o3-pro",
523
+ content: [{ type: "text", text: "hello" }],
524
+ stop_reason: "end_turn",
525
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
526
+ },
527
+ parent_tool_use_id: null,
528
+ timestamp: Date.now(),
529
+ },
530
+ {
531
+ type: "result",
532
+ data: {
533
+ type: "result",
534
+ subtype: "success",
535
+ is_error: false,
536
+ result: undefined,
537
+ duration_ms: 0,
538
+ duration_api_ms: 0,
539
+ num_turns: 1,
540
+ total_cost_usd: 0,
541
+ stop_reason: "end_turn",
542
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
543
+ uuid: "r-1",
544
+ session_id: "test-session",
545
+ },
546
+ },
547
+ {
548
+ type: "permission_request",
549
+ request: {
550
+ request_id: "perm-2",
551
+ tool_name: "Edit",
552
+ input: {},
553
+ description: "Edit file",
554
+ tool_use_id: "tool-2",
555
+ timestamp: Date.now(),
556
+ },
557
+ },
558
+ ];
559
+
560
+ for (const msg of testMessages) {
561
+ adapter._trigger("onBrowserMessage", msg);
562
+ }
563
+
564
+ // Each message should trigger one broadcastToBrowsers call, plus the initial
565
+ // cli_connected broadcast that happens during attachCodexAdapterHandlers setup.
566
+ // The first call (index 0) is cli_connected, then each message adds one more.
567
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledTimes(testMessages.length + 1);
568
+
569
+ // First call is cli_connected (from attach)
570
+ expect(deps.broadcastToBrowsers).toHaveBeenNthCalledWith(1, session, {
571
+ type: "cli_connected",
572
+ });
573
+
574
+ // Verify each subsequent call passed the session and the original message
575
+ for (let i = 0; i < testMessages.length; i++) {
576
+ expect(deps.broadcastToBrowsers).toHaveBeenNthCalledWith(
577
+ i + 2,
578
+ session,
579
+ testMessages[i],
580
+ );
581
+ }
582
+ });
583
+
584
+ // ── auto-naming via onFirstTurnCompleted ────────────────────────────────
585
+
586
+ it("result triggers session:first-turn-completed event for auto-naming on first successful result", () => {
587
+ // When a non-error result arrives and auto-naming hasn't been attempted yet,
588
+ // the handler should emit session:first-turn-completed with the first user_message content.
589
+ const onFirstTurnCompleted = vi.fn();
590
+ companionBus.on("session:first-turn-completed", onFirstTurnCompleted);
591
+
592
+ session.messageHistory.push({
593
+ type: "user_message",
594
+ content: "What is the meaning of life?",
595
+ } as any);
596
+
597
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
598
+
599
+ adapter._trigger("onBrowserMessage", {
600
+ type: "result",
601
+ data: {
602
+ type: "result",
603
+ subtype: "success",
604
+ is_error: false,
605
+ result: null,
606
+ duration_ms: 100,
607
+ duration_api_ms: 80,
608
+ num_turns: 1,
609
+ total_cost_usd: 0,
610
+ stop_reason: "end_turn",
611
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
612
+ uuid: "r-2",
613
+ session_id: "test-session",
614
+ },
615
+ });
616
+
617
+ expect(onFirstTurnCompleted).toHaveBeenCalledOnce();
618
+ expect(onFirstTurnCompleted).toHaveBeenCalledWith({
619
+ sessionId: "test-session",
620
+ firstUserMessage: "What is the meaning of life?",
621
+ });
622
+ // The session ID should be recorded in autoNamingAttempted
623
+ expect(deps.autoNamingAttempted.has("test-session")).toBe(true);
624
+ });
625
+
626
+ it("result does NOT trigger session:first-turn-completed a second time (only once per session)", () => {
627
+ // Auto-naming should only fire once per session. Subsequent results should not
628
+ // re-trigger the event.
629
+ const onFirstTurnCompleted = vi.fn();
630
+ companionBus.on("session:first-turn-completed", onFirstTurnCompleted);
631
+
632
+ session.messageHistory.push({
633
+ type: "user_message",
634
+ content: "First message",
635
+ } as any);
636
+
637
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
638
+
639
+ const resultMsg = {
640
+ type: "result",
641
+ data: {
642
+ type: "result",
643
+ subtype: "success",
644
+ is_error: false,
645
+ result: null,
646
+ duration_ms: 0,
647
+ duration_api_ms: 0,
648
+ num_turns: 1,
649
+ total_cost_usd: 0,
650
+ stop_reason: "end_turn",
651
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
652
+ uuid: "r-3",
653
+ session_id: "test-session",
654
+ },
655
+ };
656
+
657
+ adapter._trigger("onBrowserMessage", resultMsg);
658
+ adapter._trigger("onBrowserMessage", resultMsg);
659
+
660
+ // Should only be called once despite two result messages
661
+ expect(onFirstTurnCompleted).toHaveBeenCalledOnce();
662
+ });
663
+
664
+ it("result does NOT trigger session:first-turn-completed when result is an error", () => {
665
+ // Error results should not trigger auto-naming.
666
+ const onFirstTurnCompleted = vi.fn();
667
+ companionBus.on("session:first-turn-completed", onFirstTurnCompleted);
668
+
669
+ session.messageHistory.push({
670
+ type: "user_message",
671
+ content: "Some message",
672
+ } as any);
673
+
674
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
675
+
676
+ adapter._trigger("onBrowserMessage", {
677
+ type: "result",
678
+ data: {
679
+ type: "result",
680
+ subtype: "error_during_execution",
681
+ is_error: true,
682
+ result: "Something went wrong",
683
+ duration_ms: 0,
684
+ duration_api_ms: 0,
685
+ num_turns: 1,
686
+ total_cost_usd: 0,
687
+ stop_reason: "error",
688
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
689
+ uuid: "r-4",
690
+ session_id: "test-session",
691
+ },
692
+ });
693
+
694
+ expect(onFirstTurnCompleted).not.toHaveBeenCalled();
695
+ });
696
+
697
+ it("result does NOT trigger session:first-turn-completed when no user_message exists", () => {
698
+ // If there's no user_message in the history, the event should not be emitted
699
+ // even on a successful result.
700
+ const onFirstTurnCompleted = vi.fn();
701
+ companionBus.on("session:first-turn-completed", onFirstTurnCompleted);
702
+
703
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
704
+
705
+ adapter._trigger("onBrowserMessage", {
706
+ type: "result",
707
+ data: {
708
+ type: "result",
709
+ subtype: "success",
710
+ is_error: false,
711
+ result: null,
712
+ duration_ms: 0,
713
+ duration_api_ms: 0,
714
+ num_turns: 1,
715
+ total_cost_usd: 0,
716
+ stop_reason: "end_turn",
717
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
718
+ uuid: "r-5",
719
+ session_id: "test-session",
720
+ },
721
+ });
722
+
723
+ expect(onFirstTurnCompleted).not.toHaveBeenCalled();
724
+ // But the session should still be marked as naming-attempted
725
+ expect(deps.autoNamingAttempted.has("test-session")).toBe(true);
726
+ });
727
+
728
+ it("result emits session:first-turn-completed safely even with no bus subscribers", () => {
729
+ // When no subscriber is listening on the bus, the event should still be
730
+ // emitted without errors and autoNamingAttempted should be updated.
731
+ session.messageHistory.push({
732
+ type: "user_message",
733
+ content: "Some message",
734
+ } as any);
735
+
736
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
737
+
738
+ adapter._trigger("onBrowserMessage", {
739
+ type: "result",
740
+ data: {
741
+ type: "result",
742
+ subtype: "success",
743
+ is_error: false,
744
+ result: null,
745
+ duration_ms: 0,
746
+ duration_api_ms: 0,
747
+ num_turns: 1,
748
+ total_cost_usd: 0,
749
+ stop_reason: "end_turn",
750
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
751
+ uuid: "r-6",
752
+ session_id: "test-session",
753
+ },
754
+ });
755
+
756
+ // autoNamingAttempted should be set — the bus event fires even without subscribers
757
+ expect(deps.autoNamingAttempted.has("test-session")).toBe(true);
758
+ });
759
+
760
+ // ── onSessionMeta ───────────────────────────────────────────────────────
761
+
762
+ it("onSessionMeta updates model and cwd on session state", () => {
763
+ // When session metadata arrives, it should update model, cwd, and
764
+ // set backend_type to "codex", then refresh git info and persist.
765
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
766
+
767
+ adapter._trigger("onSessionMeta", {
768
+ cliSessionId: "codex-thread-123",
769
+ model: "o3-pro",
770
+ cwd: "/home/user/project",
771
+ });
772
+
773
+ expect(session.state.model).toBe("o3-pro");
774
+ expect(session.state.cwd).toBe("/home/user/project");
775
+ expect(session.state.backend_type).toBe("codex");
776
+ expect(deps.refreshGitInfo).toHaveBeenCalledWith(session, {
777
+ broadcastUpdate: true,
778
+ notifyPoller: true,
779
+ });
780
+ expect(deps.persistSession).toHaveBeenCalledWith(session);
781
+ });
782
+
783
+ it("onSessionMeta emits session:cli-id-received when cliSessionId is present", () => {
784
+ // When the meta includes a cliSessionId, the bus event should be emitted
785
+ // to track the mapping from our session ID to the Codex thread ID.
786
+ const onCLISessionId = vi.fn();
787
+ companionBus.on("session:cli-id-received", onCLISessionId);
788
+
789
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
790
+
791
+ adapter._trigger("onSessionMeta", {
792
+ cliSessionId: "codex-thread-456",
793
+ model: "gpt-4.1",
794
+ });
795
+
796
+ expect(onCLISessionId).toHaveBeenCalledWith({ sessionId: "test-session", cliSessionId: "codex-thread-456" });
797
+ });
798
+
799
+ it("onSessionMeta does not emit session:cli-id-received when cliSessionId is absent", () => {
800
+ // If no cliSessionId in the meta, the bus event should not be emitted.
801
+ const onCLISessionId = vi.fn();
802
+ companionBus.on("session:cli-id-received", onCLISessionId);
803
+
804
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
805
+
806
+ adapter._trigger("onSessionMeta", { model: "gpt-4.1" });
807
+
808
+ expect(onCLISessionId).not.toHaveBeenCalled();
809
+ });
810
+
811
+ it("onSessionMeta emits session:cli-id-received safely even with no bus subscribers", () => {
812
+ // When no subscriber is listening, the event should fire without errors.
813
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
814
+
815
+ // Should not throw
816
+ adapter._trigger("onSessionMeta", { cliSessionId: "thread-789" });
817
+
818
+ expect(session.state.backend_type).toBe("codex");
819
+ });
820
+
821
+ it("onSessionMeta handles partial meta (only model or only cwd)", () => {
822
+ // The handler should only update fields that are present in the meta object.
823
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
824
+
825
+ session.state.model = "old-model";
826
+ session.state.cwd = "/old/path";
827
+
828
+ // Only model provided
829
+ adapter._trigger("onSessionMeta", { model: "new-model" });
830
+
831
+ expect(session.state.model).toBe("new-model");
832
+ expect(session.state.cwd).toBe("/old/path"); // unchanged
833
+ });
834
+
835
+ // ── onDisconnect ────────────────────────────────────────────────────────
836
+
837
+ it("onDisconnect clears pending permissions and broadcasts cli_disconnected", () => {
838
+ // When the adapter disconnects, all pending permissions should be cancelled
839
+ // (broadcast permission_cancelled for each), the map cleared, backendAdapter set to null,
840
+ // session persisted, and a cli_disconnected message broadcast.
841
+ // Simulate the real flow: ws-bridge sets session.backendAdapter before calling handlers.
842
+ session.backendAdapter = adapter as unknown as CodexAdapter;
843
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
844
+
845
+ // Add some pending permissions first
846
+ session.pendingPermissions.set("perm-a", {
847
+ request_id: "perm-a",
848
+ tool_name: "Bash",
849
+ input: {},
850
+ description: "test",
851
+ tool_use_id: "t-a",
852
+ timestamp: Date.now(),
853
+ });
854
+ session.pendingPermissions.set("perm-b", {
855
+ request_id: "perm-b",
856
+ tool_name: "Edit",
857
+ input: {},
858
+ description: "test",
859
+ tool_use_id: "t-b",
860
+ timestamp: Date.now(),
861
+ });
862
+
863
+ adapter._trigger("onDisconnect", undefined);
864
+
865
+ // Pending permissions should be cleared
866
+ expect(session.pendingPermissions.size).toBe(0);
867
+
868
+ // backendAdapter should be nulled out
869
+ expect(session.backendAdapter).toBeNull();
870
+
871
+ // Should broadcast permission_cancelled for each pending permission
872
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
873
+ type: "permission_cancelled",
874
+ request_id: "perm-a",
875
+ });
876
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
877
+ type: "permission_cancelled",
878
+ request_id: "perm-b",
879
+ });
880
+
881
+ // Should broadcast cli_disconnected
882
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
883
+ type: "cli_disconnected",
884
+ });
885
+
886
+ expect(deps.persistSession).toHaveBeenCalled();
887
+ });
888
+
889
+ it("onDisconnect with no pending permissions still broadcasts cli_disconnected", () => {
890
+ // Even when there are no pending permissions to cancel, the disconnect handler
891
+ // should still broadcast cli_disconnected and persist.
892
+ // Simulate the real flow: ws-bridge sets session.backendAdapter before calling handlers.
893
+ session.backendAdapter = adapter as unknown as CodexAdapter;
894
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
895
+
896
+ adapter._trigger("onDisconnect", undefined);
897
+
898
+ expect(session.pendingPermissions.size).toBe(0);
899
+ expect(session.backendAdapter).toBeNull();
900
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
901
+ type: "cli_disconnected",
902
+ });
903
+ expect(deps.persistSession).toHaveBeenCalled();
904
+ });
905
+
906
+ it("onDisconnect from stale adapter is ignored when adapter has been replaced", () => {
907
+ // When a session is relaunched, the new adapter is set on session.backendAdapter
908
+ // before the old adapter's disconnect fires. The old adapter's disconnect should
909
+ // be a no-op so it doesn't null out the new adapter.
910
+ const oldAdapter = createMockAdapter();
911
+ const newAdapter = createMockAdapter();
912
+
913
+ // Simulate: old adapter is attached
914
+ session.backendAdapter = oldAdapter as unknown as CodexAdapter;
915
+ attachCodexAdapterHandlers("test-session", session, oldAdapter as unknown as CodexAdapter, deps);
916
+
917
+ // Simulate: relaunch replaces the adapter
918
+ session.backendAdapter = newAdapter as unknown as CodexAdapter;
919
+ attachCodexAdapterHandlers("test-session", session, newAdapter as unknown as CodexAdapter, deps);
920
+
921
+ // Clear broadcast calls from the two cli_connected broadcasts during attach
922
+ (deps.broadcastToBrowsers as ReturnType<typeof vi.fn>).mockClear();
923
+
924
+ // Old adapter fires disconnect (happens async after kill)
925
+ oldAdapter._trigger("onDisconnect", undefined);
926
+
927
+ // session.backendAdapter should still be the NEW adapter, not null
928
+ expect(session.backendAdapter).toBe(newAdapter);
929
+ // No cli_disconnected broadcast should have happened
930
+ expect(deps.broadcastToBrowsers).not.toHaveBeenCalledWith(session, {
931
+ type: "cli_disconnected",
932
+ });
933
+ });
934
+
935
+ it("onDisconnect triggers auto-relaunch when browsers are still connected", () => {
936
+ // When the transport drops mid-conversation and browsers are still connected,
937
+ // the session should be auto-relaunched instead of leaving users with a dead session.
938
+ const onRelaunchNeeded = vi.fn();
939
+ companionBus.on("session:relaunch-needed", onRelaunchNeeded);
940
+
941
+ session.backendAdapter = adapter as unknown as CodexAdapter;
942
+ // Simulate a connected browser
943
+ const fakeBrowserWs = {} as any;
944
+ session.browserSockets.add(fakeBrowserWs);
945
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
946
+
947
+ adapter._trigger("onDisconnect", undefined);
948
+
949
+ expect(onRelaunchNeeded).toHaveBeenCalledWith({ sessionId: "test-session" });
950
+ });
951
+
952
+ it("onDisconnect does NOT auto-relaunch when no browsers are connected", () => {
953
+ // If no browsers are watching, don't waste resources relaunching — the relaunch
954
+ // will happen when a browser reconnects via handleBrowserOpen.
955
+ const onRelaunchNeeded = vi.fn();
956
+ companionBus.on("session:relaunch-needed", onRelaunchNeeded);
957
+
958
+ session.backendAdapter = adapter as unknown as CodexAdapter;
959
+ expect(session.browserSockets.size).toBe(0);
960
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
961
+
962
+ adapter._trigger("onDisconnect", undefined);
963
+
964
+ expect(onRelaunchNeeded).not.toHaveBeenCalled();
965
+ });
966
+
967
+ it("onDisconnect works safely even with no bus subscribers for relaunch", () => {
968
+ // When no subscriber is listening for session:relaunch-needed, disconnect
969
+ // should still work without errors.
970
+ session.backendAdapter = adapter as unknown as CodexAdapter;
971
+ const fakeBrowserWs = {} as any;
972
+ session.browserSockets.add(fakeBrowserWs);
973
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
974
+
975
+ // Should not throw
976
+ adapter._trigger("onDisconnect", undefined);
977
+
978
+ expect(session.backendAdapter).toBeNull();
979
+ });
980
+
981
+ it("stale adapter disconnect does NOT trigger auto-relaunch", () => {
982
+ // When a stale adapter disconnects after being replaced, it should not
983
+ // trigger a relaunch (the new adapter is already active).
984
+ const onRelaunchNeeded = vi.fn();
985
+ companionBus.on("session:relaunch-needed", onRelaunchNeeded);
986
+
987
+ const oldAdapter = createMockAdapter();
988
+ const newAdapter = createMockAdapter();
989
+ const fakeBrowserWs = {} as any;
990
+ session.browserSockets.add(fakeBrowserWs);
991
+
992
+ session.backendAdapter = oldAdapter as unknown as CodexAdapter;
993
+ attachCodexAdapterHandlers("test-session", session, oldAdapter as unknown as CodexAdapter, deps);
994
+
995
+ // Relaunch replaces the adapter
996
+ session.backendAdapter = newAdapter as unknown as CodexAdapter;
997
+ attachCodexAdapterHandlers("test-session", session, newAdapter as unknown as CodexAdapter, deps);
998
+ onRelaunchNeeded.mockClear();
999
+
1000
+ // Old adapter fires disconnect
1001
+ oldAdapter._trigger("onDisconnect", undefined);
1002
+
1003
+ // Should NOT trigger relaunch since the old adapter was stale
1004
+ expect(onRelaunchNeeded).not.toHaveBeenCalled();
1005
+ });
1006
+
1007
+ // ── Pending message flushing ────────────────────────────────────────────
1008
+
1009
+ it("flushes pending messages to adapter on attach", () => {
1010
+ // If there are queued messages in session.pendingMessages, they should be
1011
+ // JSON-parsed and sent to the adapter via sendBrowserMessage during attach.
1012
+ const userMsg = JSON.stringify({ type: "user_message", content: "Hello" });
1013
+ const permResp = JSON.stringify({
1014
+ type: "permission_response",
1015
+ request_id: "perm-1",
1016
+ behavior: "allow",
1017
+ });
1018
+ session.pendingMessages = [userMsg, permResp];
1019
+
1020
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1021
+
1022
+ // Both messages should be sent to the adapter
1023
+ expect(adapter.sendBrowserMessage).toHaveBeenCalledTimes(2);
1024
+ expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({ type: "user_message", content: "Hello" });
1025
+ expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({
1026
+ type: "permission_response",
1027
+ request_id: "perm-1",
1028
+ behavior: "allow",
1029
+ });
1030
+
1031
+ // pendingMessages should be drained
1032
+ expect(session.pendingMessages).toHaveLength(0);
1033
+ });
1034
+
1035
+ it("does not call sendBrowserMessage when pendingMessages is empty", () => {
1036
+ // No messages to flush — sendBrowserMessage should not be called.
1037
+ session.pendingMessages = [];
1038
+
1039
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1040
+
1041
+ expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
1042
+ });
1043
+
1044
+ it("gracefully handles invalid JSON in pendingMessages", () => {
1045
+ // If a queued message is invalid JSON, it should be skipped without throwing.
1046
+ // The valid messages around it should still be flushed.
1047
+ session.pendingMessages = [
1048
+ JSON.stringify({ type: "user_message", content: "Valid" }),
1049
+ "NOT VALID JSON {{{",
1050
+ JSON.stringify({ type: "user_message", content: "Also valid" }),
1051
+ ];
1052
+
1053
+ // Should not throw
1054
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1055
+
1056
+ // Only the two valid messages should be sent
1057
+ expect(adapter.sendBrowserMessage).toHaveBeenCalledTimes(2);
1058
+ expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({ type: "user_message", content: "Valid" });
1059
+ expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({ type: "user_message", content: "Also valid" });
1060
+ expect(session.pendingMessages).toHaveLength(0);
1061
+ });
1062
+
1063
+ // ── cli_connected broadcast ─────────────────────────────────────────────
1064
+
1065
+ it("broadcasts cli_connected on attach", () => {
1066
+ // After setting up handlers and flushing pending messages, the function
1067
+ // should broadcast cli_connected to all browser sockets.
1068
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1069
+
1070
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
1071
+ type: "cli_connected",
1072
+ });
1073
+ });
1074
+
1075
+ it("broadcasts cli_connected after flushing pending messages", () => {
1076
+ // cli_connected should come after pending messages are flushed, ensuring
1077
+ // browsers know the adapter is ready only after queued work is processed.
1078
+ session.pendingMessages = [JSON.stringify({ type: "user_message", content: "Hello" })];
1079
+
1080
+ const callOrder: string[] = [];
1081
+ (adapter.sendBrowserMessage as ReturnType<typeof vi.fn>).mockImplementation(() => {
1082
+ callOrder.push("sendBrowserMessage");
1083
+ });
1084
+ (deps.broadcastToBrowsers as ReturnType<typeof vi.fn>).mockImplementation((_session: any, msg: any) => {
1085
+ if (msg.type === "cli_connected") {
1086
+ callOrder.push("cli_connected_broadcast");
1087
+ }
1088
+ });
1089
+
1090
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1091
+
1092
+ expect(callOrder).toEqual(["sendBrowserMessage", "cli_connected_broadcast"]);
1093
+ });
1094
+
1095
+ // ── AI Validation Mode ──────────────────────────────────────────────────
1096
+
1097
+ describe("AI validation mode", () => {
1098
+ /** Helper: configure settings for AI validation enabled with all auto-actions on */
1099
+ function enableAiValidation() {
1100
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1101
+ anthropicApiKey: "test-api-key",
1102
+ anthropicModel: "claude-sonnet-4-6",
1103
+ linearApiKey: "",
1104
+ linearAutoTransition: false,
1105
+ linearAutoTransitionStateId: "",
1106
+ linearAutoTransitionStateName: "",
1107
+ linearArchiveTransition: false,
1108
+ linearArchiveTransitionStateId: "",
1109
+ linearArchiveTransitionStateName: "",
1110
+ linearOAuthClientId: "",
1111
+ linearOAuthClientSecret: "",
1112
+ linearOAuthWebhookSecret: "",
1113
+ linearOAuthAccessToken: "",
1114
+ linearOAuthRefreshToken: "",
1115
+ claudeCodeOAuthToken: "",
1116
+ openaiApiKey: "",
1117
+ onboardingCompleted: false,
1118
+ aiValidationEnabled: true,
1119
+ aiValidationAutoApprove: true,
1120
+ aiValidationAutoDeny: true,
1121
+ publicUrl: "",
1122
+ updateChannel: "stable",
1123
+ dockerAutoUpdate: false,
1124
+ updatedAt: 0,
1125
+ });
1126
+ }
1127
+
1128
+ /** Helper: create a permission_request BrowserIncomingMessage for the given tool */
1129
+ function makePermissionMsg(
1130
+ toolName: string,
1131
+ requestId = "perm-ai-1",
1132
+ ): BrowserIncomingMessage {
1133
+ return {
1134
+ type: "permission_request",
1135
+ request: {
1136
+ request_id: requestId,
1137
+ tool_name: toolName,
1138
+ input: { command: "ls -la" },
1139
+ description: `Execute: ${toolName}`,
1140
+ tool_use_id: `tool-${requestId}`,
1141
+ timestamp: Date.now(),
1142
+ },
1143
+ };
1144
+ }
1145
+
1146
+ it("auto-approves when AI validation returns safe verdict", async () => {
1147
+ // When AI validation is enabled and the validator returns "safe",
1148
+ // the handler should broadcast permission_auto_resolved with behavior "allow"
1149
+ // and send a permission_response to the CLI adapter without prompting the user.
1150
+ enableAiValidation();
1151
+ vi.mocked(aiValidator.validatePermission).mockResolvedValue({
1152
+ verdict: "safe",
1153
+ reason: "Read-only tool",
1154
+ ruleBasedOnly: true,
1155
+ });
1156
+
1157
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1158
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash"));
1159
+
1160
+ // Allow the async handleCodexAiValidation to resolve
1161
+ await vi.waitFor(() => {
1162
+ expect(adapter.sendBrowserMessage).toHaveBeenCalled();
1163
+ });
1164
+
1165
+ // Should broadcast permission_auto_resolved to browsers
1166
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
1167
+ type: "permission_auto_resolved",
1168
+ request: expect.objectContaining({
1169
+ request_id: "perm-ai-1",
1170
+ tool_name: "Bash",
1171
+ ai_validation: { verdict: "safe", reason: "Read-only tool", ruleBasedOnly: true },
1172
+ }),
1173
+ behavior: "allow",
1174
+ reason: "Read-only tool",
1175
+ });
1176
+
1177
+ // Should send allow response back to CLI
1178
+ expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({
1179
+ type: "permission_response",
1180
+ request_id: "perm-ai-1",
1181
+ behavior: "allow",
1182
+ });
1183
+
1184
+ // Should NOT store in pendingPermissions (auto-resolved, no manual action needed)
1185
+ expect(session.pendingPermissions.has("perm-ai-1")).toBe(false);
1186
+ });
1187
+
1188
+ it("auto-denies when AI validation returns dangerous verdict", async () => {
1189
+ // When AI validation is enabled and the validator returns "dangerous",
1190
+ // the handler should broadcast permission_auto_resolved with behavior "deny"
1191
+ // and send a permission_response "deny" to the CLI adapter.
1192
+ enableAiValidation();
1193
+ vi.mocked(aiValidator.validatePermission).mockResolvedValue({
1194
+ verdict: "dangerous",
1195
+ reason: "Recursive delete of root directory",
1196
+ ruleBasedOnly: true,
1197
+ });
1198
+
1199
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1200
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-danger"));
1201
+
1202
+ await vi.waitFor(() => {
1203
+ expect(adapter.sendBrowserMessage).toHaveBeenCalled();
1204
+ });
1205
+
1206
+ // Should broadcast permission_auto_resolved with "deny" to browsers
1207
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
1208
+ type: "permission_auto_resolved",
1209
+ request: expect.objectContaining({
1210
+ request_id: "perm-danger",
1211
+ tool_name: "Bash",
1212
+ ai_validation: { verdict: "dangerous", reason: "Recursive delete of root directory", ruleBasedOnly: true },
1213
+ }),
1214
+ behavior: "deny",
1215
+ reason: "Recursive delete of root directory",
1216
+ });
1217
+
1218
+ // Should send deny response back to CLI
1219
+ expect(adapter.sendBrowserMessage).toHaveBeenCalledWith({
1220
+ type: "permission_response",
1221
+ request_id: "perm-danger",
1222
+ behavior: "deny",
1223
+ });
1224
+
1225
+ // Should NOT store in pendingPermissions
1226
+ expect(session.pendingPermissions.has("perm-danger")).toBe(false);
1227
+ });
1228
+
1229
+ it("falls through to manual review when AI validation returns uncertain verdict", async () => {
1230
+ // When the validator returns "uncertain", the handler should NOT auto-resolve.
1231
+ // Instead it should store the permission in pendingPermissions and broadcast
1232
+ // the permission_request to browsers for manual review, with ai_validation info attached.
1233
+ enableAiValidation();
1234
+ vi.mocked(aiValidator.validatePermission).mockResolvedValue({
1235
+ verdict: "uncertain",
1236
+ reason: "Complex bash pipeline",
1237
+ ruleBasedOnly: false,
1238
+ });
1239
+
1240
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1241
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-uncertain"));
1242
+
1243
+ await vi.waitFor(() => {
1244
+ expect(session.pendingPermissions.has("perm-uncertain")).toBe(true);
1245
+ });
1246
+
1247
+ // Should store in pendingPermissions for manual review
1248
+ const stored = session.pendingPermissions.get("perm-uncertain");
1249
+ expect(stored).toBeDefined();
1250
+ expect(stored!.ai_validation).toEqual({
1251
+ verdict: "uncertain",
1252
+ reason: "Complex bash pipeline",
1253
+ ruleBasedOnly: false,
1254
+ });
1255
+
1256
+ // Should persist session
1257
+ expect(deps.persistSession).toHaveBeenCalled();
1258
+
1259
+ // Should broadcast permission_request to browsers (not permission_auto_resolved)
1260
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
1261
+ type: "permission_request",
1262
+ request: expect.objectContaining({
1263
+ request_id: "perm-uncertain",
1264
+ ai_validation: { verdict: "uncertain", reason: "Complex bash pipeline", ruleBasedOnly: false },
1265
+ }),
1266
+ });
1267
+
1268
+ // Should NOT send any response back to CLI (user must decide)
1269
+ expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
1270
+ });
1271
+
1272
+ it("skips AI validation when disabled — uses normal permission flow", () => {
1273
+ // When aiValidationEnabled is false, the handler should go through the normal
1274
+ // flow: store in pendingPermissions, persist, and broadcast the permission_request
1275
+ // without calling validatePermission at all.
1276
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1277
+ anthropicApiKey: "test-api-key",
1278
+ anthropicModel: "claude-sonnet-4-6",
1279
+ linearApiKey: "",
1280
+ linearAutoTransition: false,
1281
+ linearAutoTransitionStateId: "",
1282
+ linearAutoTransitionStateName: "",
1283
+ linearArchiveTransition: false,
1284
+ linearArchiveTransitionStateId: "",
1285
+ linearArchiveTransitionStateName: "",
1286
+ linearOAuthClientId: "",
1287
+ linearOAuthClientSecret: "",
1288
+ linearOAuthWebhookSecret: "",
1289
+ linearOAuthAccessToken: "",
1290
+ linearOAuthRefreshToken: "",
1291
+ claudeCodeOAuthToken: "",
1292
+ openaiApiKey: "",
1293
+ onboardingCompleted: false,
1294
+ aiValidationEnabled: false, // disabled
1295
+ aiValidationAutoApprove: true,
1296
+ aiValidationAutoDeny: true,
1297
+ publicUrl: "",
1298
+ updateChannel: "stable",
1299
+ dockerAutoUpdate: false,
1300
+ updatedAt: 0,
1301
+ });
1302
+
1303
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1304
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-no-ai"));
1305
+
1306
+ // validatePermission should NOT have been called
1307
+ expect(aiValidator.validatePermission).not.toHaveBeenCalled();
1308
+
1309
+ // Should store in pendingPermissions (normal flow)
1310
+ expect(session.pendingPermissions.has("perm-no-ai")).toBe(true);
1311
+
1312
+ // Should broadcast permission_request to browsers
1313
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
1314
+ session,
1315
+ expect.objectContaining({ type: "permission_request" }),
1316
+ );
1317
+ });
1318
+
1319
+ it("skips AI validation when anthropicApiKey is empty", () => {
1320
+ // Even if aiValidationEnabled is true, an empty API key means we can't call
1321
+ // the AI — fall through to normal manual flow.
1322
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1323
+ anthropicApiKey: "", // empty
1324
+ anthropicModel: "claude-sonnet-4-6",
1325
+ linearApiKey: "",
1326
+ linearAutoTransition: false,
1327
+ linearAutoTransitionStateId: "",
1328
+ linearAutoTransitionStateName: "",
1329
+ linearArchiveTransition: false,
1330
+ linearArchiveTransitionStateId: "",
1331
+ linearArchiveTransitionStateName: "",
1332
+ linearOAuthClientId: "",
1333
+ linearOAuthClientSecret: "",
1334
+ linearOAuthWebhookSecret: "",
1335
+ linearOAuthAccessToken: "",
1336
+ linearOAuthRefreshToken: "",
1337
+ claudeCodeOAuthToken: "",
1338
+ openaiApiKey: "",
1339
+ onboardingCompleted: false,
1340
+ aiValidationEnabled: true,
1341
+ aiValidationAutoApprove: true,
1342
+ aiValidationAutoDeny: true,
1343
+ publicUrl: "",
1344
+ updateChannel: "stable",
1345
+ dockerAutoUpdate: false,
1346
+ updatedAt: 0,
1347
+ });
1348
+
1349
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1350
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-no-key"));
1351
+
1352
+ // Should NOT call AI validator
1353
+ expect(aiValidator.validatePermission).not.toHaveBeenCalled();
1354
+
1355
+ // Should fall through to normal flow
1356
+ expect(session.pendingPermissions.has("perm-no-key")).toBe(true);
1357
+ });
1358
+
1359
+ it("skips AI validation for AskUserQuestion tool even when enabled", () => {
1360
+ // AskUserQuestion is an interactive tool that always requires the user's direct
1361
+ // attention — it should never be auto-resolved by AI validation.
1362
+ enableAiValidation();
1363
+
1364
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1365
+ adapter._trigger("onBrowserMessage", makePermissionMsg("AskUserQuestion", "perm-ask"));
1366
+
1367
+ // Should NOT call AI validator
1368
+ expect(aiValidator.validatePermission).not.toHaveBeenCalled();
1369
+
1370
+ // Should go through normal flow
1371
+ expect(session.pendingPermissions.has("perm-ask")).toBe(true);
1372
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
1373
+ session,
1374
+ expect.objectContaining({ type: "permission_request" }),
1375
+ );
1376
+ });
1377
+
1378
+ it("skips AI validation for ExitPlanMode tool even when enabled", () => {
1379
+ // ExitPlanMode is an interactive tool that always requires the user's direct
1380
+ // attention — it should never be auto-resolved by AI validation.
1381
+ enableAiValidation();
1382
+
1383
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1384
+ adapter._trigger("onBrowserMessage", makePermissionMsg("ExitPlanMode", "perm-exit"));
1385
+
1386
+ // Should NOT call AI validator
1387
+ expect(aiValidator.validatePermission).not.toHaveBeenCalled();
1388
+
1389
+ // Should go through normal flow
1390
+ expect(session.pendingPermissions.has("perm-exit")).toBe(true);
1391
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
1392
+ session,
1393
+ expect.objectContaining({ type: "permission_request" }),
1394
+ );
1395
+ });
1396
+
1397
+ it("falls through to manual flow when AI validation throws an error", async () => {
1398
+ // When validatePermission rejects with an error, the .catch() handler should
1399
+ // fall through to the normal manual flow: store in pendingPermissions, persist,
1400
+ // and broadcast to browsers.
1401
+ enableAiValidation();
1402
+ vi.mocked(aiValidator.validatePermission).mockRejectedValue(
1403
+ new Error("Network timeout"),
1404
+ );
1405
+
1406
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1407
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-err"));
1408
+
1409
+ // Wait for the .catch() path to execute
1410
+ await vi.waitFor(() => {
1411
+ expect(session.pendingPermissions.has("perm-err")).toBe(true);
1412
+ });
1413
+
1414
+ // Should persist session
1415
+ expect(deps.persistSession).toHaveBeenCalled();
1416
+
1417
+ // Should broadcast permission_request to browsers for manual review
1418
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(
1419
+ session,
1420
+ expect.objectContaining({
1421
+ type: "permission_request",
1422
+ request: expect.objectContaining({ request_id: "perm-err" }),
1423
+ }),
1424
+ );
1425
+
1426
+ // Should NOT auto-resolve
1427
+ expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
1428
+ });
1429
+
1430
+ it("does not auto-approve safe verdict when aiValidationAutoApprove is false", async () => {
1431
+ // When the verdict is "safe" but auto-approve is disabled, the handler
1432
+ // should fall through to manual review instead of auto-approving.
1433
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1434
+ anthropicApiKey: "test-api-key",
1435
+ anthropicModel: "claude-sonnet-4-6",
1436
+ linearApiKey: "",
1437
+ linearAutoTransition: false,
1438
+ linearAutoTransitionStateId: "",
1439
+ linearAutoTransitionStateName: "",
1440
+ linearArchiveTransition: false,
1441
+ linearArchiveTransitionStateId: "",
1442
+ linearArchiveTransitionStateName: "",
1443
+ linearOAuthClientId: "",
1444
+ linearOAuthClientSecret: "",
1445
+ linearOAuthWebhookSecret: "",
1446
+ linearOAuthAccessToken: "",
1447
+ linearOAuthRefreshToken: "",
1448
+ claudeCodeOAuthToken: "",
1449
+ openaiApiKey: "",
1450
+ onboardingCompleted: false,
1451
+ aiValidationEnabled: true,
1452
+ aiValidationAutoApprove: false, // disabled
1453
+ aiValidationAutoDeny: true,
1454
+ publicUrl: "",
1455
+ updateChannel: "stable",
1456
+ dockerAutoUpdate: false,
1457
+ updatedAt: 0,
1458
+ });
1459
+
1460
+ vi.mocked(aiValidator.validatePermission).mockResolvedValue({
1461
+ verdict: "safe",
1462
+ reason: "Standard dev command",
1463
+ ruleBasedOnly: false,
1464
+ });
1465
+
1466
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1467
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-safe-no-auto"));
1468
+
1469
+ await vi.waitFor(() => {
1470
+ expect(session.pendingPermissions.has("perm-safe-no-auto")).toBe(true);
1471
+ });
1472
+
1473
+ // Should NOT auto-resolve — falls through to manual
1474
+ expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
1475
+
1476
+ // Should broadcast permission_request with ai_validation info attached
1477
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
1478
+ type: "permission_request",
1479
+ request: expect.objectContaining({
1480
+ request_id: "perm-safe-no-auto",
1481
+ ai_validation: { verdict: "safe", reason: "Standard dev command", ruleBasedOnly: false },
1482
+ }),
1483
+ });
1484
+ });
1485
+
1486
+ it("propagates actionable AI service error reason through to browser permission request", async () => {
1487
+ // When aiEvaluate returns an uncertain verdict due to a service failure (e.g., invalid key),
1488
+ // the specific error reason should be attached to the permission request sent to browsers,
1489
+ // allowing users to see why AI analysis failed and take corrective action.
1490
+ enableAiValidation();
1491
+ vi.mocked(aiValidator.validatePermission).mockResolvedValue({
1492
+ verdict: "uncertain",
1493
+ reason: "Invalid Anthropic API key: invalid x-api-key",
1494
+ ruleBasedOnly: false,
1495
+ });
1496
+
1497
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1498
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-api-err"));
1499
+
1500
+ await vi.waitFor(() => {
1501
+ expect(session.pendingPermissions.has("perm-api-err")).toBe(true);
1502
+ });
1503
+
1504
+ // The permission stored and broadcast should carry the actionable reason
1505
+ const stored = session.pendingPermissions.get("perm-api-err");
1506
+ expect(stored!.ai_validation).toEqual({
1507
+ verdict: "uncertain",
1508
+ reason: "Invalid Anthropic API key: invalid x-api-key",
1509
+ ruleBasedOnly: false,
1510
+ });
1511
+
1512
+ // Browser should receive the specific reason, not a generic "AI service request failed"
1513
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
1514
+ type: "permission_request",
1515
+ request: expect.objectContaining({
1516
+ request_id: "perm-api-err",
1517
+ ai_validation: expect.objectContaining({
1518
+ reason: "Invalid Anthropic API key: invalid x-api-key",
1519
+ }),
1520
+ }),
1521
+ });
1522
+
1523
+ // Manual review — no auto-resolution
1524
+ expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
1525
+ });
1526
+
1527
+ it("logs AI validation errors with session and tool context", async () => {
1528
+ // When AI validation throws, the console.warn should include session ID,
1529
+ // tool name, and request ID for debugging correlation.
1530
+ enableAiValidation();
1531
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1532
+ vi.mocked(aiValidator.validatePermission).mockRejectedValue(
1533
+ new Error("Connection refused"),
1534
+ );
1535
+
1536
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1537
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-log-test"));
1538
+
1539
+ await vi.waitFor(() => {
1540
+ expect(session.pendingPermissions.has("perm-log-test")).toBe(true);
1541
+ });
1542
+
1543
+ // The console.warn should contain session/tool/request context
1544
+ expect(warnSpy).toHaveBeenCalledWith(
1545
+ expect.stringContaining("tool=Bash"),
1546
+ expect.any(Error),
1547
+ );
1548
+ expect(warnSpy).toHaveBeenCalledWith(
1549
+ expect.stringContaining("session=test-session"),
1550
+ expect.any(Error),
1551
+ );
1552
+ expect(warnSpy).toHaveBeenCalledWith(
1553
+ expect.stringContaining("request_id=perm-log-test"),
1554
+ expect.any(Error),
1555
+ );
1556
+
1557
+ warnSpy.mockRestore();
1558
+ });
1559
+
1560
+ it("does not auto-deny dangerous verdict when aiValidationAutoDeny is false", async () => {
1561
+ // When the verdict is "dangerous" but auto-deny is disabled, the handler
1562
+ // should fall through to manual review instead of auto-denying.
1563
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1564
+ anthropicApiKey: "test-api-key",
1565
+ anthropicModel: "claude-sonnet-4-6",
1566
+ linearApiKey: "",
1567
+ linearAutoTransition: false,
1568
+ linearAutoTransitionStateId: "",
1569
+ linearAutoTransitionStateName: "",
1570
+ linearArchiveTransition: false,
1571
+ linearArchiveTransitionStateId: "",
1572
+ linearArchiveTransitionStateName: "",
1573
+ linearOAuthClientId: "",
1574
+ linearOAuthClientSecret: "",
1575
+ linearOAuthWebhookSecret: "",
1576
+ linearOAuthAccessToken: "",
1577
+ linearOAuthRefreshToken: "",
1578
+ claudeCodeOAuthToken: "",
1579
+ openaiApiKey: "",
1580
+ onboardingCompleted: false,
1581
+ aiValidationEnabled: true,
1582
+ aiValidationAutoApprove: true,
1583
+ aiValidationAutoDeny: false, // disabled
1584
+ publicUrl: "",
1585
+ updateChannel: "stable",
1586
+ dockerAutoUpdate: false,
1587
+ updatedAt: 0,
1588
+ });
1589
+
1590
+ vi.mocked(aiValidator.validatePermission).mockResolvedValue({
1591
+ verdict: "dangerous",
1592
+ reason: "Recursive delete",
1593
+ ruleBasedOnly: true,
1594
+ });
1595
+
1596
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1597
+ adapter._trigger("onBrowserMessage", makePermissionMsg("Bash", "perm-danger-no-auto"));
1598
+
1599
+ await vi.waitFor(() => {
1600
+ expect(session.pendingPermissions.has("perm-danger-no-auto")).toBe(true);
1601
+ });
1602
+
1603
+ // Should NOT auto-resolve — falls through to manual
1604
+ expect(adapter.sendBrowserMessage).not.toHaveBeenCalled();
1605
+
1606
+ // Should broadcast permission_request with ai_validation info attached
1607
+ expect(deps.broadcastToBrowsers).toHaveBeenCalledWith(session, {
1608
+ type: "permission_request",
1609
+ request: expect.objectContaining({
1610
+ request_id: "perm-danger-no-auto",
1611
+ ai_validation: { verdict: "dangerous", reason: "Recursive delete", ruleBasedOnly: true },
1612
+ }),
1613
+ });
1614
+ });
1615
+ });
1616
+
1617
+ // ── Per-session listeners (chat relay) ──────────────────────────────────
1618
+
1619
+ describe("per-session bus events (chat relay)", () => {
1620
+ it("emits message:assistant on bus when assistant message arrives", () => {
1621
+ // Chat relay relies on bus events to forward agent responses
1622
+ // to external platforms. The Codex path must emit these just like the
1623
+ // Claude Code path does.
1624
+ const listener = vi.fn();
1625
+ companionBus.on("message:assistant", ({ sessionId, message }) => {
1626
+ if (sessionId === "test-session") listener(message);
1627
+ });
1628
+
1629
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1630
+
1631
+ const assistantMsg: BrowserIncomingMessage = {
1632
+ type: "assistant",
1633
+ message: {
1634
+ id: "msg-listener",
1635
+ type: "message",
1636
+ role: "assistant",
1637
+ model: "o3-pro",
1638
+ content: [{ type: "text", text: "Hello from Codex" }],
1639
+ stop_reason: "end_turn",
1640
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1641
+ },
1642
+ parent_tool_use_id: null,
1643
+ timestamp: 1700000000000,
1644
+ };
1645
+
1646
+ adapter._trigger("onBrowserMessage", assistantMsg);
1647
+
1648
+ expect(listener).toHaveBeenCalledOnce();
1649
+ // The listener should receive the message with timestamp
1650
+ expect(listener.mock.calls[0][0]).toMatchObject({
1651
+ type: "assistant",
1652
+ timestamp: 1700000000000,
1653
+ });
1654
+ });
1655
+
1656
+ it("emits message:result on bus when result message arrives", () => {
1657
+ // Result events signal turn completion so chat relay can post
1658
+ // accumulated text back to the platform.
1659
+ const listener = vi.fn();
1660
+ companionBus.on("message:result", ({ sessionId, message }) => {
1661
+ if (sessionId === "test-session") listener(message);
1662
+ });
1663
+
1664
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1665
+
1666
+ const resultMsg: BrowserIncomingMessage = {
1667
+ type: "result",
1668
+ data: {
1669
+ type: "result",
1670
+ subtype: "success",
1671
+ is_error: false,
1672
+ result: undefined,
1673
+ duration_ms: 100,
1674
+ duration_api_ms: 80,
1675
+ num_turns: 1,
1676
+ total_cost_usd: 0.01,
1677
+ stop_reason: "end_turn",
1678
+ usage: { input_tokens: 10, output_tokens: 5, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1679
+ uuid: "result-listener-1",
1680
+ session_id: "test-session",
1681
+ },
1682
+ };
1683
+
1684
+ adapter._trigger("onBrowserMessage", resultMsg);
1685
+
1686
+ expect(listener).toHaveBeenCalledOnce();
1687
+ expect(listener.mock.calls[0][0]).toMatchObject({ type: "result" });
1688
+ });
1689
+
1690
+ it("does not invoke session-filtered listeners for a different session", () => {
1691
+ // Bus subscribers that filter by sessionId should not fire when
1692
+ // messages arrive for a different session.
1693
+ const listener = vi.fn();
1694
+ companionBus.on("message:assistant", ({ sessionId, message }) => {
1695
+ if (sessionId === "other-session") listener(message);
1696
+ });
1697
+
1698
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1699
+
1700
+ adapter._trigger("onBrowserMessage", {
1701
+ type: "assistant",
1702
+ message: {
1703
+ id: "msg-other",
1704
+ type: "message",
1705
+ role: "assistant",
1706
+ model: "o3-pro",
1707
+ content: [{ type: "text", text: "Hello" }],
1708
+ stop_reason: "end_turn",
1709
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1710
+ },
1711
+ parent_tool_use_id: null,
1712
+ timestamp: Date.now(),
1713
+ });
1714
+
1715
+ expect(listener).not.toHaveBeenCalled();
1716
+ });
1717
+
1718
+ it("does not throw when no bus subscribers are registered", () => {
1719
+ // When no subscribers are listening on the bus, emitting events
1720
+ // should not throw.
1721
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1722
+
1723
+ expect(() => {
1724
+ adapter._trigger("onBrowserMessage", {
1725
+ type: "assistant",
1726
+ message: {
1727
+ id: "msg-no-listener",
1728
+ type: "message",
1729
+ role: "assistant",
1730
+ model: "o3-pro",
1731
+ content: [{ type: "text", text: "Hello" }],
1732
+ stop_reason: "end_turn",
1733
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1734
+ },
1735
+ parent_tool_use_id: null,
1736
+ timestamp: Date.now(),
1737
+ });
1738
+ }).not.toThrow();
1739
+ });
1740
+ });
1741
+
1742
+ // ── lastCliActivityTs tracking ──────────────────────────────────────────
1743
+
1744
+ describe("lastCliActivityTs tracking for idle detection", () => {
1745
+ it("updates lastCliActivityTs when adapter emits messages", () => {
1746
+ // Codex sessions route through the adapter, not routeCLIMessage.
1747
+ // Without updating lastCliActivityTs here, the idle kill watchdog
1748
+ // would incorrectly kill active Codex sessions.
1749
+ const initialTs = session.lastCliActivityTs;
1750
+
1751
+ // Advance time — use try/finally to ensure fake timers are restored
1752
+ // even if assertions fail, preventing leaks into subsequent tests.
1753
+ vi.useFakeTimers();
1754
+ try {
1755
+ vi.advanceTimersByTime(5000);
1756
+
1757
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1758
+
1759
+ // Simulate an assistant message from Codex
1760
+ adapter._trigger("onBrowserMessage", {
1761
+ type: "assistant",
1762
+ message: {
1763
+ id: "msg-1",
1764
+ type: "message",
1765
+ role: "assistant",
1766
+ model: "o4-mini",
1767
+ content: [{ type: "text", text: "Hi" }],
1768
+ stop_reason: "end_turn",
1769
+ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
1770
+ },
1771
+ parent_tool_use_id: null,
1772
+ timestamp: Date.now(),
1773
+ });
1774
+
1775
+ // lastCliActivityTs should have been updated
1776
+ expect(session.lastCliActivityTs).toBeGreaterThan(initialTs);
1777
+ } finally {
1778
+ vi.useRealTimers();
1779
+ }
1780
+ });
1781
+
1782
+ it("updates lastCliActivityTs on result messages", () => {
1783
+ // Result messages (turn completed) should also update activity tracking
1784
+ const oldTs = Date.now() - 60000;
1785
+ session.lastCliActivityTs = oldTs;
1786
+
1787
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1788
+
1789
+ adapter._trigger("onBrowserMessage", {
1790
+ type: "result",
1791
+ subtype: "result",
1792
+ data: { result: "Task completed" },
1793
+ });
1794
+
1795
+ expect(session.lastCliActivityTs).toBeGreaterThan(oldTs);
1796
+ });
1797
+
1798
+ it("updates lastCliActivityTs on session_init messages", () => {
1799
+ // Even session_init should count as activity
1800
+ const oldTs = Date.now() - 60000;
1801
+ session.lastCliActivityTs = oldTs;
1802
+
1803
+ attachCodexAdapterHandlers("test-session", session, adapter as unknown as CodexAdapter, deps);
1804
+
1805
+ adapter._trigger("onBrowserMessage", {
1806
+ type: "session_init",
1807
+ session: {
1808
+ session_id: "test-session",
1809
+ backend_type: "codex",
1810
+ model: "o4-mini",
1811
+ cwd: "/tmp",
1812
+ tools: [],
1813
+ permissionMode: "bypassPermissions",
1814
+ claude_code_version: "",
1815
+ mcp_servers: [],
1816
+ agents: [],
1817
+ slash_commands: [],
1818
+ skills: [],
1819
+ total_cost_usd: 0,
1820
+ num_turns: 0,
1821
+ context_used_percent: 0,
1822
+ is_compacting: false,
1823
+ git_branch: "",
1824
+ is_worktree: false,
1825
+ is_containerized: false,
1826
+ repo_root: "",
1827
+ git_ahead: 0,
1828
+ git_behind: 0,
1829
+ total_lines_added: 0,
1830
+ total_lines_removed: 0,
1831
+ },
1832
+ });
1833
+
1834
+ expect(session.lastCliActivityTs).toBeGreaterThan(oldTs);
1835
+ });
1836
+ });
1837
+ });