@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,1784 @@
1
+ import { vi, describe, it, expect, beforeEach } from "vitest";
2
+
3
+ // ── Module mocks ────────────────────────────────────────────────────────────
4
+ // Must be declared before any imports that reference them.
5
+
6
+ vi.mock("./env-manager.js", () => ({
7
+ getEnv: vi.fn(() => null),
8
+ }));
9
+
10
+ vi.mock("./sandbox-manager.js", () => ({
11
+ getSandbox: vi.fn(() => null),
12
+ }));
13
+
14
+ vi.mock("./git-utils.js", () => ({
15
+ getRepoInfo: vi.fn(() => null),
16
+ gitFetch: vi.fn(() => ({ success: true, output: "" })),
17
+ gitPull: vi.fn(() => ({ success: true, output: "" })),
18
+ checkoutOrCreateBranch: vi.fn(() => ({ created: false })),
19
+ ensureWorktree: vi.fn(() => ({ worktreePath: "/wt/feat", actualBranch: "feat", isNew: true })),
20
+ isWorktreeDirty: vi.fn(() => false),
21
+ removeWorktree: vi.fn(() => ({ removed: true })),
22
+ }));
23
+
24
+ vi.mock("./session-names.js", () => ({
25
+ getName: vi.fn(() => undefined),
26
+ setName: vi.fn(),
27
+ getAllNames: vi.fn(() => ({})),
28
+ removeName: vi.fn(),
29
+ }));
30
+
31
+ vi.mock("./session-linear-issues.js", () => ({
32
+ getLinearIssue: vi.fn(() => undefined),
33
+ setLinearIssue: vi.fn(),
34
+ removeLinearIssue: vi.fn(),
35
+ getAllLinearIssues: vi.fn(() => ({})),
36
+ }));
37
+
38
+ vi.mock("./settings-manager.js", () => ({
39
+ getSettings: vi.fn(() => ({
40
+ anthropicApiKey: "",
41
+ anthropicModel: "claude-sonnet-4-6",
42
+ linearApiKey: "",
43
+ linearAutoTransition: false,
44
+ linearAutoTransitionStateId: "",
45
+ linearAutoTransitionStateName: "",
46
+ linearArchiveTransition: false,
47
+ linearArchiveTransitionStateId: "",
48
+ linearArchiveTransitionStateName: "",
49
+ claudeCodeOAuthToken: "",
50
+ openaiApiKey: "",
51
+ onboardingCompleted: false,
52
+ })),
53
+ }));
54
+
55
+ vi.mock("./linear-connections.js", () => ({
56
+ getConnection: vi.fn(() => null),
57
+ resolveApiKey: vi.fn(() => null),
58
+ }));
59
+
60
+ vi.mock("./linear-prompt-builder.js", () => ({
61
+ buildLinearSystemPrompt: vi.fn(() => ""),
62
+ }));
63
+
64
+ vi.mock("./routes/linear-routes.js", () => ({
65
+ transitionLinearIssue: vi.fn(async () => ({ ok: true })),
66
+ fetchLinearTeamStates: vi.fn(async () => []),
67
+ }));
68
+
69
+ vi.mock("./claude-container-auth.js", () => ({
70
+ hasContainerClaudeAuth: vi.fn(() => true),
71
+ }));
72
+
73
+ vi.mock("./codex-container-auth.js", () => ({
74
+ hasContainerCodexAuth: vi.fn(() => true),
75
+ }));
76
+
77
+ vi.mock("./commands-discovery.js", () => ({
78
+ discoverCommandsAndSkills: vi.fn(async () => ({ slash_commands: [], skills: [] })),
79
+ }));
80
+
81
+ vi.mock("./auto-namer.js", () => ({
82
+ generateSessionTitle: vi.fn(async () => "Test Title"),
83
+ }));
84
+
85
+ const mockImagePullIsReady = vi.hoisted(() => vi.fn(() => true));
86
+ const mockImagePullGetState = vi.hoisted(() => vi.fn(() => ({ image: "", status: "ready", progress: [] })));
87
+ const mockImagePullEnsureImage = vi.hoisted(() => vi.fn());
88
+ const mockImagePullWaitForReady = vi.hoisted(() => vi.fn(async () => true));
89
+ const mockImagePullOnProgress = vi.hoisted(() => vi.fn(() => () => {}));
90
+
91
+ vi.mock("./image-pull-manager.js", () => ({
92
+ imagePullManager: {
93
+ isReady: mockImagePullIsReady,
94
+ getState: mockImagePullGetState,
95
+ ensureImage: mockImagePullEnsureImage,
96
+ waitForReady: mockImagePullWaitForReady,
97
+ onProgress: mockImagePullOnProgress,
98
+ },
99
+ }));
100
+
101
+ vi.mock("./container-manager.js", () => ({
102
+ containerManager: {
103
+ removeContainer: vi.fn(),
104
+ createContainer: vi.fn(() => ({
105
+ containerId: "cid-1",
106
+ name: "companion-1",
107
+ image: "the-companion:latest",
108
+ portMappings: [],
109
+ hostCwd: "/test",
110
+ containerCwd: "/workspace",
111
+ state: "running",
112
+ })),
113
+ imageExists: vi.fn(() => true),
114
+ retrack: vi.fn(),
115
+ copyWorkspaceToContainer: vi.fn(async () => {}),
116
+ reseedGitAuth: vi.fn(),
117
+ gitOpsInContainer: vi.fn(() => ({
118
+ fetchOk: true,
119
+ checkoutOk: true,
120
+ pullOk: true,
121
+ errors: [],
122
+ })),
123
+ execInContainerAsync: vi.fn(async () => ({ exitCode: 0, output: "ok" })),
124
+ isContainerAlive: vi.fn(() => "not_found"),
125
+ },
126
+ }));
127
+
128
+ // ── Imports (after mocks) ───────────────────────────────────────────────────
129
+
130
+ import { SessionOrchestrator } from "./session-orchestrator.js";
131
+ import type { SessionOrchestratorDeps } from "./session-orchestrator.js";
132
+ import { containerManager } from "./container-manager.js";
133
+ import * as envManager from "./env-manager.js";
134
+ import * as sandboxManager from "./sandbox-manager.js";
135
+ import * as gitUtils from "./git-utils.js";
136
+ import * as sessionNames from "./session-names.js";
137
+ import * as sessionLinearIssues from "./session-linear-issues.js";
138
+ import * as settingsManager from "./settings-manager.js";
139
+ import { resolveApiKey } from "./linear-connections.js";
140
+ import { transitionLinearIssue, fetchLinearTeamStates } from "./routes/linear-routes.js";
141
+ import { hasContainerClaudeAuth } from "./claude-container-auth.js";
142
+ import { hasContainerCodexAuth } from "./codex-container-auth.js";
143
+ import { generateSessionTitle } from "./auto-namer.js";
144
+ import { companionBus } from "./event-bus.js";
145
+
146
+ // ── Mock factories ──────────────────────────────────────────────────────────
147
+
148
+ function createMockLauncher() {
149
+ return {
150
+ launch: vi.fn(() => ({
151
+ sessionId: "session-1",
152
+ state: "starting",
153
+ cwd: "/test",
154
+ createdAt: Date.now(),
155
+ })),
156
+ kill: vi.fn(async () => true),
157
+ relaunch: vi.fn(async () => ({ ok: true })),
158
+ listSessions: vi.fn(() => []),
159
+ getSession: vi.fn(() => undefined),
160
+ setArchived: vi.fn(),
161
+ removeSession: vi.fn(),
162
+ setCLISessionId: vi.fn(),
163
+ getStartingSessions: vi.fn(() => []),
164
+ } as any;
165
+ }
166
+
167
+ function createMockBridge() {
168
+ return {
169
+ closeSession: vi.fn(),
170
+ isCliConnected: vi.fn(() => false),
171
+ getSession: vi.fn(() => null),
172
+ getAllSessions: vi.fn(() => []),
173
+ markContainerized: vi.fn(),
174
+ prePopulateCommands: vi.fn(),
175
+ broadcastNameUpdate: vi.fn(),
176
+ broadcastToSession: vi.fn(),
177
+ injectSystemPrompt: vi.fn(),
178
+ attachBackendAdapter: vi.fn(),
179
+ cancelDisconnectTimer: vi.fn(() => false),
180
+ } as any;
181
+ }
182
+
183
+ function createMockStore() {
184
+ return {
185
+ setArchived: vi.fn(() => true),
186
+ } as any;
187
+ }
188
+
189
+ function createMockTracker() {
190
+ return {
191
+ addMapping: vi.fn(),
192
+ getBySession: vi.fn(() => null),
193
+ removeBySession: vi.fn(),
194
+ isWorktreeInUse: vi.fn(() => false),
195
+ } as any;
196
+ }
197
+
198
+ function createDeps(overrides?: Partial<SessionOrchestratorDeps>) {
199
+ const launcher = createMockLauncher();
200
+ const wsBridge = createMockBridge();
201
+ const sessionStore = createMockStore();
202
+ const worktreeTracker = createMockTracker();
203
+ const prPoller = { watch: vi.fn(), unwatch: vi.fn() };
204
+ const agentExecutor = { handleSessionExited: vi.fn() } as any;
205
+ return {
206
+ launcher,
207
+ wsBridge,
208
+ sessionStore,
209
+ worktreeTracker,
210
+ prPoller,
211
+ agentExecutor,
212
+ ...overrides,
213
+ };
214
+ }
215
+
216
+ // ── Tests ───────────────────────────────────────────────────────────────────
217
+
218
+ describe("SessionOrchestrator", () => {
219
+ let deps: ReturnType<typeof createDeps>;
220
+ let orchestrator: SessionOrchestrator;
221
+
222
+ beforeEach(() => {
223
+ vi.clearAllMocks();
224
+ companionBus.clear();
225
+ mockImagePullIsReady.mockReturnValue(true);
226
+ // Re-establish mocks that may have been overridden by mockImplementation in
227
+ // previous tests (clearAllMocks resets calls/results but NOT implementations).
228
+ vi.mocked(hasContainerClaudeAuth).mockReturnValue(true);
229
+ vi.mocked(hasContainerCodexAuth).mockReturnValue(true);
230
+ vi.mocked(containerManager.createContainer).mockReturnValue({
231
+ containerId: "cid-1",
232
+ name: "companion-1",
233
+ image: "the-companion:latest",
234
+ portMappings: [],
235
+ hostCwd: "/test",
236
+ containerCwd: "/workspace",
237
+ state: "running",
238
+ } as any);
239
+ vi.mocked(containerManager.gitOpsInContainer).mockReturnValue({
240
+ fetchOk: true,
241
+ checkoutOk: true,
242
+ pullOk: true,
243
+ errors: [],
244
+ } as any);
245
+ vi.mocked(containerManager.execInContainerAsync).mockResolvedValue({ exitCode: 0, output: "ok" });
246
+ deps = createDeps();
247
+ orchestrator = new SessionOrchestrator(deps);
248
+ });
249
+
250
+ // ── Initialization / Event wiring ─────────────────────────────────────────
251
+
252
+ describe("initialize()", () => {
253
+ it("registers all expected event listeners on companionBus", () => {
254
+ // Verifies that initialize() wires up all event handlers on the bus
255
+ orchestrator.initialize();
256
+
257
+ expect(companionBus.listenerCount("session:cli-id-received")).toBeGreaterThan(0);
258
+ expect(companionBus.listenerCount("backend:codex-adapter-created")).toBeGreaterThan(0);
259
+ expect(companionBus.listenerCount("session:exited")).toBeGreaterThan(0);
260
+ expect(companionBus.listenerCount("session:git-info-ready")).toBeGreaterThan(0);
261
+ expect(companionBus.listenerCount("session:relaunch-needed")).toBeGreaterThan(0);
262
+ expect(companionBus.listenerCount("session:idle-kill")).toBeGreaterThan(0);
263
+ expect(companionBus.listenerCount("session:first-turn-completed")).toBeGreaterThan(0);
264
+ });
265
+
266
+ it("CLI session ID callback delegates to launcher.setCLISessionId", () => {
267
+ orchestrator.initialize();
268
+
269
+ // Emit event on the bus instead of extracting callback
270
+ companionBus.emit("session:cli-id-received", { sessionId: "s1", cliSessionId: "cli-id-123" });
271
+
272
+ expect(deps.launcher.setCLISessionId).toHaveBeenCalledWith("s1", "cli-id-123");
273
+ });
274
+
275
+ it("session exit callback notifies agentExecutor", () => {
276
+ orchestrator.initialize();
277
+
278
+ companionBus.emit("session:exited", { sessionId: "s1", exitCode: 0 });
279
+
280
+ expect(deps.agentExecutor.handleSessionExited).toHaveBeenCalledWith("s1", 0);
281
+ });
282
+
283
+ it("git info ready callback starts PR polling", () => {
284
+ orchestrator.initialize();
285
+
286
+ companionBus.emit("session:git-info-ready", { sessionId: "s1", cwd: "/repo", branch: "main" });
287
+
288
+ expect(deps.prPoller.watch).toHaveBeenCalledWith("s1", "/repo", "main");
289
+ });
290
+
291
+ it("idle kill callback does not kill archived sessions", async () => {
292
+ deps.launcher.getSession.mockReturnValue({ archived: true });
293
+ orchestrator.initialize();
294
+
295
+ companionBus.emit("session:idle-kill", { sessionId: "s1" });
296
+ await new Promise(r => setTimeout(r, 0));
297
+
298
+ // Should not kill because session is archived
299
+ expect(deps.launcher.kill).not.toHaveBeenCalled();
300
+ });
301
+
302
+ it("idle kill callback kills CLI but preserves container", async () => {
303
+ deps.launcher.getSession.mockReturnValue({ archived: false });
304
+ orchestrator.initialize();
305
+
306
+ companionBus.emit("session:idle-kill", { sessionId: "s1" });
307
+ await new Promise(r => setTimeout(r, 0));
308
+
309
+ expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
310
+ // Container must NOT be removed — idle-kill only stops the CLI process
311
+ // so the container can be reused on relaunch.
312
+ expect(containerManager.removeContainer).not.toHaveBeenCalled();
313
+ });
314
+
315
+ it("after idle-kill, relaunch reuses preserved container without creating a new one", async () => {
316
+ // End-to-end scenario: idle-kill fires, container survives, browser
317
+ // reconnects, and the CLI is relaunched into the existing container.
318
+ vi.useFakeTimers();
319
+ deps.launcher.getSession.mockReturnValue({
320
+ archived: false,
321
+ state: "exited",
322
+ containerId: "cid-preserved",
323
+ pid: undefined,
324
+ } as any);
325
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
326
+ deps.launcher.relaunch.mockResolvedValue({ ok: true });
327
+ orchestrator.initialize();
328
+
329
+ // 1. Idle-kill fires — CLI killed, container preserved
330
+ companionBus.emit("session:idle-kill", { sessionId: "s1" });
331
+ await vi.advanceTimersByTimeAsync(0);
332
+ expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
333
+ expect(containerManager.removeContainer).not.toHaveBeenCalled();
334
+
335
+ // 2. Browser reconnects — triggers auto-relaunch
336
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
337
+ await vi.advanceTimersByTimeAsync(15_000);
338
+ await vi.advanceTimersByTimeAsync(0);
339
+
340
+ // 3. Relaunch succeeds using the preserved container — no new container created
341
+ expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
342
+ expect(containerManager.createContainer).not.toHaveBeenCalled();
343
+
344
+ vi.useRealTimers();
345
+ });
346
+
347
+ it("idle kill clears auto-relaunch counter so session can be fully relaunched later", async () => {
348
+ // After idle-kill, the auto-relaunch counter must be reset. Without this,
349
+ // a session that previously had failed relaunch attempts would be stuck at
350
+ // max and never relaunch when the user returns.
351
+ vi.useFakeTimers();
352
+ deps.launcher.getSession.mockReturnValue({ archived: false, state: "exited", pid: undefined } as any);
353
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
354
+ deps.launcher.relaunch.mockResolvedValue({ ok: false, error: "failed" });
355
+ orchestrator.initialize();
356
+
357
+ // Exhaust 2 of 3 relaunch attempts
358
+ for (let i = 0; i < 2; i++) {
359
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
360
+ await vi.advanceTimersByTimeAsync(15_000);
361
+ await vi.advanceTimersByTimeAsync(0);
362
+ }
363
+ expect(deps.launcher.relaunch).toHaveBeenCalledTimes(2);
364
+
365
+ // Now idle-kill the session — this should clear the counter
366
+ companionBus.emit("session:idle-kill", { sessionId: "s1" });
367
+ await vi.advanceTimersByTimeAsync(0);
368
+
369
+ // After idle-kill, we should get a fresh budget of 3 relaunch attempts.
370
+ // Reset the mock to track new calls.
371
+ deps.launcher.relaunch.mockClear();
372
+ deps.launcher.relaunch.mockResolvedValue({ ok: false, error: "failed" });
373
+
374
+ for (let i = 0; i < 3; i++) {
375
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
376
+ await vi.advanceTimersByTimeAsync(15_000);
377
+ await vi.advanceTimersByTimeAsync(0);
378
+ }
379
+
380
+ // All 3 attempts should succeed (not blocked by previous count)
381
+ expect(deps.launcher.relaunch).toHaveBeenCalledTimes(3);
382
+ vi.useRealTimers();
383
+ });
384
+
385
+ it("is idempotent — calling initialize() twice does not double-register listeners", () => {
386
+ // Guards against accidental re-initialization which would cause
387
+ // all event handlers to fire multiple times per event.
388
+ orchestrator.initialize();
389
+ const countsAfterFirst = {
390
+ cliId: companionBus.listenerCount("session:cli-id-received"),
391
+ codex: companionBus.listenerCount("backend:codex-adapter-created"),
392
+ exited: companionBus.listenerCount("session:exited"),
393
+ relaunch: companionBus.listenerCount("session:relaunch-needed"),
394
+ idleKill: companionBus.listenerCount("session:idle-kill"),
395
+ firstTurn: companionBus.listenerCount("session:first-turn-completed"),
396
+ };
397
+
398
+ orchestrator.initialize();
399
+
400
+ // Listener counts should not have doubled after the second initialize()
401
+ expect(companionBus.listenerCount("session:cli-id-received")).toBe(countsAfterFirst.cliId);
402
+ expect(companionBus.listenerCount("backend:codex-adapter-created")).toBe(countsAfterFirst.codex);
403
+ expect(companionBus.listenerCount("session:exited")).toBe(countsAfterFirst.exited);
404
+ expect(companionBus.listenerCount("session:relaunch-needed")).toBe(countsAfterFirst.relaunch);
405
+ expect(companionBus.listenerCount("session:idle-kill")).toBe(countsAfterFirst.idleKill);
406
+ expect(companionBus.listenerCount("session:first-turn-completed")).toBe(countsAfterFirst.firstTurn);
407
+ });
408
+ });
409
+
410
+ // ── Session Creation ──────────────────────────────────────────────────────
411
+
412
+ describe("createSession()", () => {
413
+ it("creates a basic session with defaults", async () => {
414
+ const result = await orchestrator.createSession({ cwd: "/test" });
415
+
416
+ expect(result.ok).toBe(true);
417
+ if (result.ok) {
418
+ expect(result.session.sessionId).toBe("session-1");
419
+ }
420
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
421
+ expect.objectContaining({
422
+ cwd: "/test",
423
+ backendType: "claude",
424
+ }),
425
+ );
426
+ });
427
+
428
+ it("returns 400 for invalid backend", async () => {
429
+ const result = await orchestrator.createSession({ cwd: "/test", backend: "invalid" });
430
+
431
+ expect(result.ok).toBe(false);
432
+ if (!result.ok) {
433
+ expect(result.error).toContain("Invalid backend");
434
+ expect(result.status).toBe(400);
435
+ }
436
+ });
437
+
438
+ it("resolves environment variables from envSlug", async () => {
439
+ vi.mocked(envManager.getEnv).mockReturnValue({
440
+ name: "Production",
441
+ slug: "production",
442
+ variables: { API_KEY: "secret", DB_HOST: "db.example.com" },
443
+ createdAt: 1000,
444
+ updatedAt: 1000,
445
+ });
446
+
447
+ const result = await orchestrator.createSession({ cwd: "/test", envSlug: "production" });
448
+
449
+ expect(result.ok).toBe(true);
450
+ expect(envManager.getEnv).toHaveBeenCalledWith("production");
451
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
452
+ expect.objectContaining({
453
+ env: expect.objectContaining({ API_KEY: "secret", DB_HOST: "db.example.com" }),
454
+ }),
455
+ );
456
+ });
457
+
458
+ // ── Global token injection from settings ───────────────────────────
459
+
460
+ // Verifies that CLAUDE_CODE_OAUTH_TOKEN is injected from global settings
461
+ // when the session backend is "claude" and no token is already set
462
+ it("injects CLAUDE_CODE_OAUTH_TOKEN from global settings for claude backend", async () => {
463
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
464
+ ...settingsManager.getSettings(),
465
+ claudeCodeOAuthToken: "global-oauth-token",
466
+ });
467
+
468
+ await orchestrator.createSession({ cwd: "/test", backend: "claude" });
469
+
470
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
471
+ expect.objectContaining({
472
+ env: expect.objectContaining({ CLAUDE_CODE_OAUTH_TOKEN: "global-oauth-token" }),
473
+ }),
474
+ );
475
+ });
476
+
477
+ // Verifies that OPENAI_API_KEY is injected from global settings
478
+ // when the session backend is "codex" and no key is already set
479
+ it("injects OPENAI_API_KEY from global settings for codex backend", async () => {
480
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
481
+ ...settingsManager.getSettings(),
482
+ openaiApiKey: "sk-global-key",
483
+ });
484
+
485
+ await orchestrator.createSession({ cwd: "/test", backend: "codex" });
486
+
487
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
488
+ expect.objectContaining({
489
+ env: expect.objectContaining({ OPENAI_API_KEY: "sk-global-key" }),
490
+ }),
491
+ );
492
+ });
493
+
494
+ // Verifies that env-profile tokens take precedence over global settings
495
+ it("does not overwrite CLAUDE_CODE_OAUTH_TOKEN when already set by env profile", async () => {
496
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
497
+ ...settingsManager.getSettings(),
498
+ claudeCodeOAuthToken: "global-token",
499
+ });
500
+ vi.mocked(envManager.getEnv).mockReturnValue({
501
+ name: "Custom",
502
+ slug: "custom",
503
+ variables: { CLAUDE_CODE_OAUTH_TOKEN: "env-profile-token" },
504
+ createdAt: 1000,
505
+ updatedAt: 1000,
506
+ });
507
+
508
+ await orchestrator.createSession({ cwd: "/test", backend: "claude", envSlug: "custom" });
509
+
510
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
511
+ expect.objectContaining({
512
+ env: expect.objectContaining({ CLAUDE_CODE_OAUTH_TOKEN: "env-profile-token" }),
513
+ }),
514
+ );
515
+ });
516
+
517
+ // Verifies that no token is injected when global settings have empty values
518
+ it("does not inject token when global setting is empty", async () => {
519
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
520
+ ...settingsManager.getSettings(),
521
+ claudeCodeOAuthToken: "",
522
+ openaiApiKey: "",
523
+ });
524
+
525
+ await orchestrator.createSession({ cwd: "/test", backend: "claude" });
526
+
527
+ const launchCall = vi.mocked(deps.launcher.launch).mock.calls[0][0];
528
+ expect(launchCall.env?.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
529
+ });
530
+
531
+ it("validates branch name to prevent injection", async () => {
532
+ const result = await orchestrator.createSession({ cwd: "/test", branch: "bad branch name!" });
533
+
534
+ expect(result.ok).toBe(false);
535
+ if (!result.ok) {
536
+ expect(result.error).toContain("Invalid branch name");
537
+ expect(result.status).toBe(400);
538
+ }
539
+ });
540
+
541
+ it("performs git fetch, checkout, and pull for non-docker branch", async () => {
542
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
543
+ repoRoot: "/repo",
544
+ repoName: "my-repo",
545
+ currentBranch: "develop",
546
+ defaultBranch: "main",
547
+ isWorktree: false,
548
+ });
549
+
550
+ const result = await orchestrator.createSession({ cwd: "/repo", branch: "main" });
551
+
552
+ expect(result.ok).toBe(true);
553
+ expect(gitUtils.gitFetch).toHaveBeenCalledWith("/repo");
554
+ expect(gitUtils.checkoutOrCreateBranch).toHaveBeenCalledWith("/repo", "main", {
555
+ createBranch: undefined,
556
+ defaultBranch: "main",
557
+ });
558
+ expect(gitUtils.gitPull).toHaveBeenCalledWith("/repo");
559
+ });
560
+
561
+ it("skips checkout when branch matches current branch", async () => {
562
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
563
+ repoRoot: "/repo",
564
+ repoName: "my-repo",
565
+ currentBranch: "main",
566
+ defaultBranch: "main",
567
+ isWorktree: false,
568
+ });
569
+
570
+ await orchestrator.createSession({ cwd: "/repo", branch: "main" });
571
+
572
+ expect(gitUtils.gitFetch).toHaveBeenCalled();
573
+ expect(gitUtils.checkoutOrCreateBranch).not.toHaveBeenCalled();
574
+ expect(gitUtils.gitPull).toHaveBeenCalled();
575
+ });
576
+
577
+ it("creates worktree when useWorktree is true", async () => {
578
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
579
+ repoRoot: "/repo",
580
+ repoName: "my-repo",
581
+ currentBranch: "main",
582
+ defaultBranch: "main",
583
+ isWorktree: false,
584
+ });
585
+ vi.mocked(gitUtils.ensureWorktree).mockReturnValue({
586
+ worktreePath: "/wt/feat",
587
+ branch: "feat",
588
+ actualBranch: "feat",
589
+ isNew: true,
590
+ } as any);
591
+
592
+ const result = await orchestrator.createSession({ cwd: "/repo", branch: "feat", useWorktree: true });
593
+
594
+ expect(result.ok).toBe(true);
595
+ expect(gitUtils.ensureWorktree).toHaveBeenCalledWith("/repo", "feat", {
596
+ baseBranch: "main",
597
+ createBranch: undefined,
598
+ forceNew: true,
599
+ });
600
+ // Launch should use worktree path as cwd
601
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
602
+ expect.objectContaining({ cwd: "/wt/feat" }),
603
+ );
604
+ // Should track the worktree mapping
605
+ expect(deps.worktreeTracker.addMapping).toHaveBeenCalledWith(
606
+ expect.objectContaining({
607
+ sessionId: "session-1",
608
+ repoRoot: "/repo",
609
+ branch: "feat",
610
+ worktreePath: "/wt/feat",
611
+ }),
612
+ );
613
+ });
614
+
615
+ it("proceeds when git fetch fails (non-fatal)", async () => {
616
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
617
+ repoRoot: "/repo",
618
+ repoName: "my-repo",
619
+ currentBranch: "main",
620
+ defaultBranch: "main",
621
+ isWorktree: false,
622
+ });
623
+ vi.mocked(gitUtils.gitFetch).mockReturnValue({ success: false, output: "network error" });
624
+
625
+ const result = await orchestrator.createSession({ cwd: "/repo", branch: "main" });
626
+
627
+ expect(result.ok).toBe(true);
628
+ expect(deps.launcher.launch).toHaveBeenCalled();
629
+ });
630
+
631
+ it("returns 400 when containerized Claude lacks auth", async () => {
632
+ vi.mocked(hasContainerClaudeAuth).mockReturnValue(false);
633
+ vi.mocked(envManager.getEnv).mockReturnValue({
634
+ name: "E",
635
+ slug: "e",
636
+ variables: {},
637
+ createdAt: 1,
638
+ updatedAt: 1,
639
+ } as any);
640
+
641
+ const result = await orchestrator.createSession({
642
+ cwd: "/test",
643
+ sandboxEnabled: true,
644
+ envSlug: "e",
645
+ });
646
+
647
+ expect(result.ok).toBe(false);
648
+ if (!result.ok) {
649
+ expect(result.error).toContain("Containerized Claude requires auth");
650
+ expect(result.status).toBe(400);
651
+ }
652
+ });
653
+
654
+ it("returns 400 when containerized Codex lacks auth", async () => {
655
+ vi.mocked(hasContainerCodexAuth).mockReturnValue(false);
656
+ vi.mocked(envManager.getEnv).mockReturnValue({
657
+ name: "E",
658
+ slug: "e",
659
+ variables: {},
660
+ createdAt: 1,
661
+ updatedAt: 1,
662
+ } as any);
663
+
664
+ const result = await orchestrator.createSession({
665
+ cwd: "/test",
666
+ backend: "codex",
667
+ sandboxEnabled: true,
668
+ envSlug: "e",
669
+ });
670
+
671
+ expect(result.ok).toBe(false);
672
+ if (!result.ok) {
673
+ expect(result.error).toContain("Containerized Codex requires auth");
674
+ expect(result.status).toBe(400);
675
+ }
676
+ });
677
+
678
+ it("creates container for sandboxed sessions", async () => {
679
+ vi.mocked(envManager.getEnv).mockReturnValue({
680
+ name: "Docker",
681
+ slug: "docker",
682
+ variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
683
+ createdAt: 1,
684
+ updatedAt: 1,
685
+ } as any);
686
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue({
687
+ name: "Docker",
688
+ slug: "docker",
689
+ createdAt: 1,
690
+ updatedAt: 1,
691
+ });
692
+
693
+ const result = await orchestrator.createSession({
694
+ cwd: "/test",
695
+ envSlug: "docker",
696
+ sandboxEnabled: true,
697
+ sandboxSlug: "docker",
698
+ });
699
+
700
+ expect(result.ok).toBe(true);
701
+ expect(containerManager.createContainer).toHaveBeenCalled();
702
+ expect(containerManager.copyWorkspaceToContainer).toHaveBeenCalled();
703
+ expect(containerManager.retrack).toHaveBeenCalledWith("cid-1", "session-1");
704
+ expect(deps.wsBridge.markContainerized).toHaveBeenCalledWith("session-1", "/test");
705
+ });
706
+
707
+ it("returns 503 when container creation fails", async () => {
708
+ vi.mocked(envManager.getEnv).mockReturnValue({
709
+ name: "E",
710
+ slug: "e",
711
+ variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
712
+ createdAt: 1,
713
+ updatedAt: 1,
714
+ } as any);
715
+ vi.mocked(containerManager.createContainer).mockImplementation(() => {
716
+ throw new Error("docker daemon timeout");
717
+ });
718
+
719
+ const result = await orchestrator.createSession({
720
+ cwd: "/test",
721
+ sandboxEnabled: true,
722
+ envSlug: "e",
723
+ });
724
+
725
+ expect(result.ok).toBe(false);
726
+ if (!result.ok) {
727
+ expect(result.error).toContain("container startup failed");
728
+ expect(result.status).toBe(503);
729
+ }
730
+ });
731
+
732
+ it("runs init script for sandbox sessions", async () => {
733
+ vi.mocked(envManager.getEnv).mockReturnValue({
734
+ name: "E",
735
+ slug: "e",
736
+ variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
737
+ createdAt: 1,
738
+ updatedAt: 1,
739
+ } as any);
740
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue({
741
+ name: "E",
742
+ slug: "e",
743
+ initScript: "npm install",
744
+ createdAt: 1,
745
+ updatedAt: 1,
746
+ });
747
+
748
+ const result = await orchestrator.createSession({
749
+ cwd: "/test",
750
+ sandboxEnabled: true,
751
+ sandboxSlug: "e",
752
+ envSlug: "e",
753
+ });
754
+
755
+ expect(result.ok).toBe(true);
756
+ expect(containerManager.execInContainerAsync).toHaveBeenCalledWith(
757
+ "cid-1",
758
+ ["sh", "-lc", "npm install"],
759
+ expect.objectContaining({ timeout: expect.any(Number) }),
760
+ );
761
+ });
762
+
763
+ it("returns 503 when init script fails", async () => {
764
+ vi.mocked(envManager.getEnv).mockReturnValue({
765
+ name: "E",
766
+ slug: "e",
767
+ variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
768
+ createdAt: 1,
769
+ updatedAt: 1,
770
+ } as any);
771
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue({
772
+ name: "E",
773
+ slug: "e",
774
+ initScript: "exit 1",
775
+ createdAt: 1,
776
+ updatedAt: 1,
777
+ });
778
+ vi.mocked(containerManager.execInContainerAsync).mockResolvedValue({ exitCode: 1, output: "npm ERR!" });
779
+
780
+ const result = await orchestrator.createSession({
781
+ cwd: "/test",
782
+ sandboxEnabled: true,
783
+ sandboxSlug: "e",
784
+ envSlug: "e",
785
+ });
786
+
787
+ expect(result.ok).toBe(false);
788
+ if (!result.ok) {
789
+ expect(result.error).toContain("Init script failed");
790
+ expect(result.status).toBe(503);
791
+ // Container should be cleaned up
792
+ expect(containerManager.removeContainer).toHaveBeenCalled();
793
+ }
794
+ });
795
+
796
+ it("runs git ops inside container for Docker sessions with branch", async () => {
797
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
798
+ repoRoot: "/repo",
799
+ repoName: "my-repo",
800
+ currentBranch: "main",
801
+ defaultBranch: "main",
802
+ isWorktree: false,
803
+ } as any);
804
+ vi.mocked(envManager.getEnv).mockReturnValue({
805
+ name: "Docker",
806
+ slug: "docker",
807
+ variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
808
+ createdAt: 1,
809
+ updatedAt: 1,
810
+ } as any);
811
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue({
812
+ name: "Docker",
813
+ slug: "docker",
814
+ createdAt: 1,
815
+ updatedAt: 1,
816
+ });
817
+
818
+ const result = await orchestrator.createSession({
819
+ cwd: "/repo",
820
+ branch: "feat/new",
821
+ envSlug: "docker",
822
+ sandboxEnabled: true,
823
+ sandboxSlug: "docker",
824
+ });
825
+
826
+ expect(result.ok).toBe(true);
827
+ // Host git ops should NOT have been called
828
+ expect(gitUtils.gitFetch).not.toHaveBeenCalled();
829
+ expect(gitUtils.checkoutOrCreateBranch).not.toHaveBeenCalled();
830
+ expect(gitUtils.gitPull).not.toHaveBeenCalled();
831
+ // In-container git ops SHOULD have been called
832
+ expect(containerManager.gitOpsInContainer).toHaveBeenCalledWith(
833
+ "cid-1",
834
+ expect.objectContaining({ branch: "feat/new", currentBranch: "main" }),
835
+ );
836
+ });
837
+
838
+ it("returns 400 when in-container checkout fails", async () => {
839
+ vi.mocked(gitUtils.getRepoInfo).mockReturnValue({
840
+ repoRoot: "/repo",
841
+ repoName: "my-repo",
842
+ currentBranch: "main",
843
+ defaultBranch: "main",
844
+ isWorktree: false,
845
+ } as any);
846
+ vi.mocked(envManager.getEnv).mockReturnValue({
847
+ name: "E",
848
+ slug: "e",
849
+ variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
850
+ createdAt: 1,
851
+ updatedAt: 1,
852
+ } as any);
853
+ vi.mocked(sandboxManager.getSandbox).mockReturnValue({
854
+ name: "E",
855
+ slug: "e",
856
+ createdAt: 1,
857
+ updatedAt: 1,
858
+ });
859
+ vi.mocked(containerManager.gitOpsInContainer).mockReturnValue({
860
+ fetchOk: true,
861
+ checkoutOk: false,
862
+ pullOk: false,
863
+ errors: ['branch "nonexistent" does not exist'],
864
+ });
865
+
866
+ const result = await orchestrator.createSession({
867
+ cwd: "/repo",
868
+ branch: "nonexistent",
869
+ sandboxEnabled: true,
870
+ sandboxSlug: "e",
871
+ envSlug: "e",
872
+ });
873
+
874
+ expect(result.ok).toBe(false);
875
+ if (!result.ok) {
876
+ expect(result.error).toContain("Failed to checkout branch");
877
+ expect(result.status).toBe(400);
878
+ expect(containerManager.removeContainer).toHaveBeenCalled();
879
+ }
880
+ });
881
+
882
+ it("passes resumeSessionAt and forkSession to launcher", async () => {
883
+ const result = await orchestrator.createSession({
884
+ cwd: "/test",
885
+ resumeSessionAt: " existing-session-id ",
886
+ forkSession: true,
887
+ });
888
+
889
+ expect(result.ok).toBe(true);
890
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
891
+ expect.objectContaining({
892
+ resumeSessionAt: "existing-session-id",
893
+ forkSession: true,
894
+ }),
895
+ );
896
+ });
897
+
898
+ it("passes backendType codex to launcher", async () => {
899
+ const result = await orchestrator.createSession({
900
+ cwd: "/test",
901
+ backend: "codex",
902
+ model: "gpt-5",
903
+ });
904
+
905
+ expect(result.ok).toBe(true);
906
+ expect(deps.launcher.launch).toHaveBeenCalledWith(
907
+ expect.objectContaining({ backendType: "codex", model: "gpt-5" }),
908
+ );
909
+ });
910
+
911
+ it("catches thrown errors from launcher.launch and returns 503", async () => {
912
+ deps.launcher.launch.mockImplementation(() => {
913
+ throw new Error("CLI binary not found");
914
+ });
915
+
916
+ const result = await orchestrator.createSession({ cwd: "/test" });
917
+
918
+ expect(result.ok).toBe(false);
919
+ if (!result.ok) {
920
+ expect(result.error).toContain("CLI binary not found");
921
+ expect(result.status).toBe(503);
922
+ }
923
+ });
924
+
925
+ it("cleans up container when launcher.launch throws after container creation", async () => {
926
+ // If a container was created but launcher.launch throws, the container
927
+ // should be cleaned up to avoid leaking Docker resources.
928
+ vi.mocked(envManager.getEnv).mockReturnValue({
929
+ name: "E",
930
+ slug: "e",
931
+ variables: { CLAUDE_CODE_OAUTH_TOKEN: "token" },
932
+ createdAt: 1,
933
+ updatedAt: 1,
934
+ } as any);
935
+ deps.launcher.launch.mockImplementation(() => {
936
+ throw new Error("Binary not found");
937
+ });
938
+
939
+ const result = await orchestrator.createSession({
940
+ cwd: "/test",
941
+ sandboxEnabled: true,
942
+ envSlug: "e",
943
+ });
944
+
945
+ expect(result.ok).toBe(false);
946
+ if (!result.ok) {
947
+ expect(result.error).toContain("Failed to launch CLI");
948
+ expect(result.status).toBe(503);
949
+ }
950
+ // Container should be cleaned up after launch failure
951
+ expect(containerManager.removeContainer).toHaveBeenCalled();
952
+ });
953
+ });
954
+
955
+ // ── Streaming Session Creation ────────────────────────────────────────────
956
+
957
+ describe("createSessionStreaming()", () => {
958
+ it("calls progress callback during creation", async () => {
959
+ const onProgress = vi.fn();
960
+ const result = await orchestrator.createSessionStreaming({ cwd: "/test" }, onProgress);
961
+
962
+ expect(result.ok).toBe(true);
963
+ // Should have at least resolving_env and launching_cli progress events
964
+ expect(onProgress).toHaveBeenCalledWith("resolving_env", expect.any(String), "in_progress");
965
+ expect(onProgress).toHaveBeenCalledWith("resolving_env", expect.any(String), "done");
966
+ expect(onProgress).toHaveBeenCalledWith("launching_cli", expect.any(String), "in_progress");
967
+ expect(onProgress).toHaveBeenCalledWith("launching_cli", expect.any(String), "done");
968
+ });
969
+
970
+ it("emits correct label for codex backend", async () => {
971
+ const onProgress = vi.fn();
972
+ await orchestrator.createSessionStreaming({ cwd: "/test", backend: "codex" }, onProgress);
973
+
974
+ expect(onProgress).toHaveBeenCalledWith("launching_cli", "Launching Codex...", "in_progress");
975
+ });
976
+
977
+ it("emits correct label for claude backend", async () => {
978
+ const onProgress = vi.fn();
979
+ await orchestrator.createSessionStreaming({ cwd: "/test" }, onProgress);
980
+
981
+ expect(onProgress).toHaveBeenCalledWith("launching_cli", "Launching Claude Code...", "in_progress");
982
+ });
983
+ });
984
+
985
+ // ── Kill ───────────────────────────────────────────────────────────────────
986
+
987
+ describe("killSession()", () => {
988
+ it("kills launcher and removes container", async () => {
989
+ deps.launcher.kill.mockResolvedValue(true);
990
+ const result = await orchestrator.killSession("s1");
991
+
992
+ expect(result.ok).toBe(true);
993
+ expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
994
+ expect(containerManager.removeContainer).toHaveBeenCalledWith("s1");
995
+ });
996
+
997
+ it("returns ok=false and does not remove container when session not found", async () => {
998
+ // When launcher.kill returns false (session not found), removeContainer
999
+ // should NOT be called to preserve the original behavior from routes.ts.
1000
+ deps.launcher.kill.mockResolvedValue(false);
1001
+ const result = await orchestrator.killSession("s1");
1002
+
1003
+ expect(result.ok).toBe(false);
1004
+ expect(containerManager.removeContainer).not.toHaveBeenCalled();
1005
+ });
1006
+ });
1007
+
1008
+ // ── Relaunch ──────────────────────────────────────────────────────────────
1009
+
1010
+ describe("relaunchSession()", () => {
1011
+ it("delegates to launcher.relaunch", async () => {
1012
+ const result = await orchestrator.relaunchSession("s1");
1013
+
1014
+ expect(result.ok).toBe(true);
1015
+ expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
1016
+ });
1017
+
1018
+ it("rejects relaunching archived sessions", async () => {
1019
+ deps.launcher.getSession.mockReturnValue({ archived: true });
1020
+
1021
+ const result = await orchestrator.relaunchSession("s1");
1022
+
1023
+ expect(result.ok).toBe(false);
1024
+ expect(result.error).toContain("archived");
1025
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1026
+ });
1027
+
1028
+ it("propagates error from launcher.relaunch", async () => {
1029
+ deps.launcher.relaunch.mockResolvedValue({ ok: false, error: "Container removed externally" });
1030
+
1031
+ const result = await orchestrator.relaunchSession("s1");
1032
+
1033
+ expect(result.ok).toBe(false);
1034
+ expect(result.error).toContain("Container removed externally");
1035
+ });
1036
+ });
1037
+
1038
+ // ── Archive ───────────────────────────────────────────────────────────────
1039
+
1040
+ describe("archiveSession()", () => {
1041
+ it("kills, removes container, unwatches PR, and marks archived", async () => {
1042
+ const result = await orchestrator.archiveSession("s1");
1043
+
1044
+ expect(result.ok).toBe(true);
1045
+ expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
1046
+ expect(containerManager.removeContainer).toHaveBeenCalledWith("s1");
1047
+ expect(deps.prPoller.unwatch).toHaveBeenCalledWith("s1");
1048
+ expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", true);
1049
+ expect(deps.sessionStore.setArchived).toHaveBeenCalledWith("s1", true);
1050
+ });
1051
+
1052
+ it("performs Linear transition when linearTransition=backlog", async () => {
1053
+ // Set up linked issue
1054
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue({
1055
+ id: "issue-1",
1056
+ identifier: "ENG-42",
1057
+ teamId: "team-1",
1058
+ connectionId: "conn-1",
1059
+ } as any);
1060
+ vi.mocked(resolveApiKey).mockReturnValue({ apiKey: "lin_api_123", connectionId: "conn-1" });
1061
+ vi.mocked(fetchLinearTeamStates).mockResolvedValue([
1062
+ {
1063
+ id: "team-1",
1064
+ key: "ENG",
1065
+ name: "Engineering",
1066
+ states: [
1067
+ { id: "state-backlog", name: "Backlog", type: "backlog" },
1068
+ { id: "state-done", name: "Done", type: "completed" },
1069
+ ],
1070
+ },
1071
+ ]);
1072
+ vi.mocked(transitionLinearIssue).mockResolvedValue({
1073
+ ok: true,
1074
+ issue: { id: "issue-1", identifier: "ENG-42", stateName: "Backlog", stateType: "backlog" },
1075
+ } as any);
1076
+
1077
+ const result = await orchestrator.archiveSession("s1", { linearTransition: "backlog" });
1078
+
1079
+ expect(result.ok).toBe(true);
1080
+ expect(fetchLinearTeamStates).toHaveBeenCalledWith("lin_api_123");
1081
+ expect(transitionLinearIssue).toHaveBeenCalledWith("issue-1", "state-backlog", "lin_api_123", "conn-1");
1082
+ // Session should still be archived even with transition
1083
+ expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", true);
1084
+ });
1085
+
1086
+ it("archives even when Linear transition fails", async () => {
1087
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue({
1088
+ id: "issue-1",
1089
+ identifier: "ENG-42",
1090
+ teamId: "team-1",
1091
+ connectionId: "conn-1",
1092
+ } as any);
1093
+ vi.mocked(resolveApiKey).mockReturnValue({ apiKey: "lin_api_123", connectionId: "conn-1" });
1094
+ vi.mocked(fetchLinearTeamStates).mockResolvedValue([{
1095
+ id: "team-1",
1096
+ key: "ENG",
1097
+ name: "Engineering",
1098
+ states: [{ id: "state-backlog", name: "Backlog", type: "backlog" }],
1099
+ }]);
1100
+ vi.mocked(transitionLinearIssue).mockResolvedValue({ ok: false, error: "API error" });
1101
+
1102
+ const result = await orchestrator.archiveSession("s1", { linearTransition: "backlog" });
1103
+
1104
+ expect(result.ok).toBe(true);
1105
+ expect(result.linearTransition?.ok).toBe(false);
1106
+ expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", true);
1107
+ });
1108
+
1109
+ it("catches thrown transition errors and still archives", async () => {
1110
+ // When transitionLinearIssue throws, archiveSession should catch it
1111
+ // and continue with the archive operation.
1112
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue({
1113
+ id: "issue-1",
1114
+ identifier: "ENG-42",
1115
+ teamId: "team-1",
1116
+ connectionId: "conn-1",
1117
+ } as any);
1118
+ vi.mocked(resolveApiKey).mockReturnValue({ apiKey: "lin_api_123", connectionId: "conn-1" });
1119
+ vi.mocked(fetchLinearTeamStates).mockResolvedValue([{
1120
+ id: "team-1",
1121
+ key: "ENG",
1122
+ name: "Engineering",
1123
+ states: [{ id: "state-backlog", name: "Backlog", type: "backlog" }],
1124
+ }]);
1125
+ vi.mocked(transitionLinearIssue).mockRejectedValue(new Error("Network error"));
1126
+
1127
+ const result = await orchestrator.archiveSession("s1", { linearTransition: "backlog" });
1128
+
1129
+ expect(result.ok).toBe(true);
1130
+ expect(result.linearTransition).toEqual({ ok: false, error: "Transition failed unexpectedly" });
1131
+ expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", true);
1132
+ });
1133
+
1134
+ it("skips transition when no target state found", async () => {
1135
+ // When the target state cannot be found (e.g., team has no backlog state),
1136
+ // linearTransition should be marked as skipped.
1137
+ vi.mocked(sessionLinearIssues.getLinearIssue).mockReturnValue({
1138
+ id: "issue-1",
1139
+ identifier: "ENG-42",
1140
+ teamId: "team-1",
1141
+ connectionId: "conn-1",
1142
+ } as any);
1143
+ vi.mocked(resolveApiKey).mockReturnValue({ apiKey: "lin_api_123", connectionId: "conn-1" });
1144
+ vi.mocked(fetchLinearTeamStates).mockResolvedValue([{
1145
+ id: "team-1",
1146
+ key: "ENG",
1147
+ name: "Engineering",
1148
+ states: [{ id: "state-done", name: "Done", type: "completed" }],
1149
+ // No backlog state
1150
+ }]);
1151
+
1152
+ const result = await orchestrator.archiveSession("s1", { linearTransition: "backlog" });
1153
+
1154
+ expect(result.ok).toBe(true);
1155
+ expect(result.linearTransition).toEqual({ ok: true, skipped: true });
1156
+ });
1157
+
1158
+ it("cleans up worktree during archive", async () => {
1159
+ deps.worktreeTracker.getBySession.mockReturnValue({
1160
+ sessionId: "s1",
1161
+ repoRoot: "/repo",
1162
+ branch: "feat",
1163
+ worktreePath: "/wt/feat",
1164
+ createdAt: 1000,
1165
+ });
1166
+
1167
+ const result = await orchestrator.archiveSession("s1");
1168
+
1169
+ expect(result.ok).toBe(true);
1170
+ expect(result.worktree).toMatchObject({ cleaned: true, path: "/wt/feat" });
1171
+ expect(gitUtils.removeWorktree).toHaveBeenCalledWith("/repo", "/wt/feat", {
1172
+ force: false,
1173
+ branchToDelete: undefined,
1174
+ });
1175
+ });
1176
+ });
1177
+
1178
+ // ── Delete ────────────────────────────────────────────────────────────────
1179
+
1180
+ describe("deleteSession()", () => {
1181
+ it("performs full cleanup: kill, container, worktree, PR, Linear, bridge", async () => {
1182
+ const result = await orchestrator.deleteSession("s1");
1183
+
1184
+ expect(result.ok).toBe(true);
1185
+ expect(deps.launcher.kill).toHaveBeenCalledWith("s1");
1186
+ expect(containerManager.removeContainer).toHaveBeenCalledWith("s1");
1187
+ expect(deps.prPoller.unwatch).toHaveBeenCalledWith("s1");
1188
+ expect(sessionLinearIssues.removeLinearIssue).toHaveBeenCalledWith("s1");
1189
+ expect(deps.launcher.removeSession).toHaveBeenCalledWith("s1");
1190
+ expect(deps.wsBridge.closeSession).toHaveBeenCalledWith("s1");
1191
+ });
1192
+
1193
+ it("returns worktree cleanup info", async () => {
1194
+ deps.worktreeTracker.getBySession.mockReturnValue({
1195
+ sessionId: "s1",
1196
+ repoRoot: "/repo",
1197
+ branch: "feat",
1198
+ worktreePath: "/wt/feat",
1199
+ createdAt: 1000,
1200
+ });
1201
+
1202
+ const result = await orchestrator.deleteSession("s1");
1203
+
1204
+ expect(result.ok).toBe(true);
1205
+ expect(result.worktree).toMatchObject({ cleaned: true, path: "/wt/feat" });
1206
+ });
1207
+
1208
+ it("passes branchToDelete when actualBranch differs from branch", async () => {
1209
+ // When actualBranch differs from branch, the worktree-unique branch should be deleted.
1210
+ // force=true in deleteSession means "skip dirty check", but removeWorktree gets
1211
+ // force: dirty (isWorktreeDirty() result), which is false by default.
1212
+ deps.worktreeTracker.getBySession.mockReturnValue({
1213
+ sessionId: "s1",
1214
+ repoRoot: "/repo",
1215
+ branch: "feat",
1216
+ actualBranch: "feat-wt-1234",
1217
+ worktreePath: "/wt/feat",
1218
+ createdAt: 1000,
1219
+ });
1220
+
1221
+ await orchestrator.deleteSession("s1");
1222
+
1223
+ expect(gitUtils.removeWorktree).toHaveBeenCalledWith("/repo", "/wt/feat", {
1224
+ force: false,
1225
+ branchToDelete: "feat-wt-1234",
1226
+ });
1227
+ });
1228
+
1229
+ it("removes container unconditionally during delete (unlike kill)", async () => {
1230
+ // deleteSession always removes the container, even if kill reports no process found,
1231
+ // because we're permanently removing the session and must clean up all resources.
1232
+ deps.launcher.kill.mockResolvedValue(false);
1233
+
1234
+ await orchestrator.deleteSession("s1");
1235
+
1236
+ expect(containerManager.removeContainer).toHaveBeenCalledWith("s1");
1237
+ });
1238
+ });
1239
+
1240
+ // ── Unarchive ─────────────────────────────────────────────────────────────
1241
+
1242
+ describe("unarchiveSession()", () => {
1243
+ it("unsets archived flag on launcher and store", () => {
1244
+ const result = orchestrator.unarchiveSession("s1");
1245
+
1246
+ expect(result.ok).toBe(true);
1247
+ expect(deps.launcher.setArchived).toHaveBeenCalledWith("s1", false);
1248
+ expect(deps.sessionStore.setArchived).toHaveBeenCalledWith("s1", false);
1249
+ });
1250
+ });
1251
+
1252
+ // ── Auto-naming ───────────────────────────────────────────────────────────
1253
+
1254
+ describe("handleAutoNaming (via initialize)", () => {
1255
+ it("generates title when anthropicApiKey is set and no name exists", async () => {
1256
+ vi.mocked(settingsManager.getSettings).mockReturnValue({
1257
+ anthropicApiKey: "sk-ant-123",
1258
+ } as any);
1259
+ vi.mocked(sessionNames.getName).mockReturnValue(undefined);
1260
+ deps.launcher.getSession.mockReturnValue({ model: "claude-sonnet-4-6" });
1261
+ vi.mocked(generateSessionTitle).mockResolvedValue("Test Title");
1262
+
1263
+ orchestrator.initialize();
1264
+ companionBus.emit("session:first-turn-completed", { sessionId: "s1", firstUserMessage: "Hello world" });
1265
+ await new Promise(r => setTimeout(r, 0));
1266
+
1267
+ expect(generateSessionTitle).toHaveBeenCalledWith("Hello world", "claude-sonnet-4-6");
1268
+ expect(sessionNames.setName).toHaveBeenCalledWith("s1", "Test Title");
1269
+ expect(deps.wsBridge.broadcastNameUpdate).toHaveBeenCalledWith("s1", "Test Title");
1270
+ });
1271
+
1272
+ it("skips naming when session already has a name", async () => {
1273
+ vi.mocked(settingsManager.getSettings).mockReturnValue({ anthropicApiKey: "sk-ant-123" } as any);
1274
+ vi.mocked(sessionNames.getName).mockReturnValue("Existing Name");
1275
+
1276
+ orchestrator.initialize();
1277
+ companionBus.emit("session:first-turn-completed", { sessionId: "s1", firstUserMessage: "Hello" });
1278
+ await new Promise(r => setTimeout(r, 0));
1279
+
1280
+ expect(generateSessionTitle).not.toHaveBeenCalled();
1281
+ });
1282
+
1283
+ it("skips naming when no API key is configured", async () => {
1284
+ vi.mocked(settingsManager.getSettings).mockReturnValue({ anthropicApiKey: "" } as any);
1285
+
1286
+ orchestrator.initialize();
1287
+ companionBus.emit("session:first-turn-completed", { sessionId: "s1", firstUserMessage: "Hello" });
1288
+ await new Promise(r => setTimeout(r, 0));
1289
+
1290
+ expect(generateSessionTitle).not.toHaveBeenCalled();
1291
+ });
1292
+ });
1293
+
1294
+ // ── Reconnection watchdog ─────────────────────────────────────────────────
1295
+
1296
+ describe("startReconnectionWatchdog (via initialize)", () => {
1297
+ it("does nothing when no sessions are starting", () => {
1298
+ deps.launcher.getStartingSessions.mockReturnValue([]);
1299
+ orchestrator.initialize();
1300
+
1301
+ // No error thrown, no relaunch called
1302
+ expect(deps.launcher.getStartingSessions).toHaveBeenCalled();
1303
+ });
1304
+
1305
+ it("schedules relaunch for stale starting sessions", async () => {
1306
+ vi.useFakeTimers();
1307
+ try {
1308
+ deps.launcher.getStartingSessions
1309
+ .mockReturnValueOnce([{ sessionId: "s1", state: "starting" }])
1310
+ .mockReturnValueOnce([{ sessionId: "s1", state: "starting" }]);
1311
+
1312
+ orchestrator.initialize();
1313
+
1314
+ // Advance past the reconnect grace period (default 30s)
1315
+ await vi.advanceTimersByTimeAsync(30_000);
1316
+
1317
+ expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
1318
+ } finally {
1319
+ vi.useRealTimers();
1320
+ }
1321
+ });
1322
+
1323
+ it("skips archived sessions during reconnection watchdog", async () => {
1324
+ vi.useFakeTimers();
1325
+ try {
1326
+ deps.launcher.getStartingSessions
1327
+ .mockReturnValueOnce([{ sessionId: "s1", state: "starting" }])
1328
+ .mockReturnValueOnce([{ sessionId: "s1", state: "starting", archived: true }]);
1329
+
1330
+ orchestrator.initialize();
1331
+ await vi.advanceTimersByTimeAsync(30_000);
1332
+
1333
+ // Should NOT relaunch archived session
1334
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1335
+ } finally {
1336
+ vi.useRealTimers();
1337
+ }
1338
+ });
1339
+ });
1340
+
1341
+ // ── Worktree cleanup ──────────────────────────────────────────────────────
1342
+
1343
+ describe("cleanupWorktree (via deleteSession/archiveSession)", () => {
1344
+ it("returns undefined when session has no worktree mapping", async () => {
1345
+ deps.worktreeTracker.getBySession.mockReturnValue(null);
1346
+
1347
+ const result = await orchestrator.deleteSession("s1");
1348
+
1349
+ expect(result.worktree).toBeUndefined();
1350
+ });
1351
+
1352
+ it("does not remove worktree in use by another session", async () => {
1353
+ deps.worktreeTracker.getBySession.mockReturnValue({
1354
+ sessionId: "s1",
1355
+ repoRoot: "/repo",
1356
+ branch: "feat",
1357
+ worktreePath: "/wt/feat",
1358
+ createdAt: 1000,
1359
+ });
1360
+ deps.worktreeTracker.isWorktreeInUse.mockReturnValue(true);
1361
+
1362
+ const result = await orchestrator.deleteSession("s1");
1363
+
1364
+ expect(result.worktree).toMatchObject({ cleaned: false, path: "/wt/feat" });
1365
+ expect(gitUtils.removeWorktree).not.toHaveBeenCalled();
1366
+ });
1367
+
1368
+ it("does not remove dirty worktree unless forced", async () => {
1369
+ deps.worktreeTracker.getBySession.mockReturnValue({
1370
+ sessionId: "s1",
1371
+ repoRoot: "/repo",
1372
+ branch: "feat",
1373
+ worktreePath: "/wt/feat",
1374
+ createdAt: 1000,
1375
+ });
1376
+ vi.mocked(gitUtils.isWorktreeDirty).mockReturnValue(true);
1377
+
1378
+ // Archive without force
1379
+ const result = await orchestrator.archiveSession("s1");
1380
+
1381
+ expect(result.worktree).toMatchObject({ cleaned: false, dirty: true, path: "/wt/feat" });
1382
+ expect(gitUtils.removeWorktree).not.toHaveBeenCalled();
1383
+ });
1384
+
1385
+ it("force-removes dirty worktree when force=true", async () => {
1386
+ deps.worktreeTracker.getBySession.mockReturnValue({
1387
+ sessionId: "s1",
1388
+ repoRoot: "/repo",
1389
+ branch: "feat",
1390
+ worktreePath: "/wt/feat",
1391
+ createdAt: 1000,
1392
+ });
1393
+ vi.mocked(gitUtils.isWorktreeDirty).mockReturnValue(true);
1394
+
1395
+ const result = await orchestrator.archiveSession("s1", { force: true });
1396
+
1397
+ expect(result.worktree).toMatchObject({ cleaned: true, path: "/wt/feat" });
1398
+ expect(gitUtils.removeWorktree).toHaveBeenCalledWith("/repo", "/wt/feat", {
1399
+ force: true,
1400
+ branchToDelete: undefined,
1401
+ });
1402
+ });
1403
+ });
1404
+
1405
+ // ── getSession ────────────────────────────────────────────────────────────
1406
+
1407
+ describe("getSession()", () => {
1408
+ it("delegates to launcher.getSession", () => {
1409
+ const mockSession = { sessionId: "s1", state: "connected" };
1410
+ deps.launcher.getSession.mockReturnValue(mockSession);
1411
+
1412
+ const result = orchestrator.getSession("s1");
1413
+
1414
+ expect(result).toBe(mockSession);
1415
+ expect(deps.launcher.getSession).toHaveBeenCalledWith("s1");
1416
+ });
1417
+
1418
+ it("returns undefined for unknown session", () => {
1419
+ deps.launcher.getSession.mockReturnValue(undefined);
1420
+
1421
+ const result = orchestrator.getSession("unknown");
1422
+
1423
+ expect(result).toBeUndefined();
1424
+ });
1425
+ });
1426
+
1427
+ // ── Auto-relaunch ──────────────────────────────────────────────────────────
1428
+
1429
+ describe("handleAutoRelaunch (via initialize)", () => {
1430
+ beforeEach(() => {
1431
+ vi.useFakeTimers();
1432
+ });
1433
+
1434
+ afterEach(() => {
1435
+ vi.useRealTimers();
1436
+ });
1437
+
1438
+ it("skips relaunch for archived sessions", async () => {
1439
+ // Archived sessions should not be auto-relaunched.
1440
+ deps.launcher.getSession.mockReturnValue({ archived: true } as any);
1441
+ orchestrator.initialize();
1442
+
1443
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1444
+ // Advance past the grace period and flush microtasks for the async handler
1445
+ await vi.advanceTimersByTimeAsync(15_000);
1446
+ await vi.advanceTimersByTimeAsync(0);
1447
+
1448
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1449
+ });
1450
+
1451
+ it("skips relaunch when CLI reconnects during grace period", async () => {
1452
+ // During the grace period, if CLI reconnects, relaunch should be skipped.
1453
+ deps.launcher.getSession.mockReturnValue({ archived: false } as any);
1454
+ deps.wsBridge.isCliConnected.mockReturnValue(true);
1455
+ orchestrator.initialize();
1456
+
1457
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1458
+ await vi.advanceTimersByTimeAsync(15_000);
1459
+ await vi.advanceTimersByTimeAsync(0);
1460
+
1461
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1462
+ });
1463
+
1464
+ it("skips relaunch when session state is 'connected' after grace", async () => {
1465
+ // If the session reconnects (state=connected) during grace, skip relaunch.
1466
+ deps.launcher.getSession
1467
+ .mockReturnValueOnce({ archived: false } as any) // check archived
1468
+ .mockReturnValueOnce({ state: "connected" } as any); // after grace
1469
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1470
+ orchestrator.initialize();
1471
+
1472
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1473
+ await vi.advanceTimersByTimeAsync(15_000);
1474
+ await vi.advanceTimersByTimeAsync(0);
1475
+
1476
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1477
+ });
1478
+
1479
+ it("skips relaunch when session is still starting", async () => {
1480
+ // A session in "starting" state should not be relaunched — it's still
1481
+ // initializing. The starting guard at line 771 prevents this.
1482
+ deps.launcher.getSession
1483
+ .mockReturnValueOnce({ archived: false } as any) // check archived
1484
+ .mockReturnValueOnce({ state: "starting", pid: process.pid } as any); // after grace: still starting
1485
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1486
+ orchestrator.initialize();
1487
+
1488
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1489
+ await vi.advanceTimersByTimeAsync(15_000);
1490
+ await vi.advanceTimersByTimeAsync(0);
1491
+
1492
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1493
+ });
1494
+
1495
+ it("relaunches exited session even when PID was recycled to a live process", async () => {
1496
+ // After idle-kill, the session state is "exited" but the PID field stays
1497
+ // set. If the kernel recycles the PID to a different process, we must NOT
1498
+ // let the PID check prevent relaunch. The fix skips PID liveness for
1499
+ // exited sessions entirely.
1500
+ deps.launcher.getSession
1501
+ .mockReturnValueOnce({ archived: false } as any) // check archived
1502
+ .mockReturnValueOnce({ state: "exited", pid: process.pid } as any); // after grace: PID is alive (recycled!)
1503
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1504
+ orchestrator.initialize();
1505
+
1506
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1507
+ await vi.advanceTimersByTimeAsync(15_000);
1508
+ await vi.advanceTimersByTimeAsync(0);
1509
+
1510
+ // Should relaunch despite the PID being alive — exited sessions skip PID check
1511
+ expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
1512
+ });
1513
+
1514
+ it("skips relaunch for containerized session when container is still running", async () => {
1515
+ // For non-exited containerized sessions, use container liveness instead
1516
+ // of PID check. If the container is running, skip relaunch to let the
1517
+ // CLI reconnect on its own. Use state "starting" to bypass the earlier
1518
+ // connected/running guard and actually exercise the container check path.
1519
+ vi.mocked(containerManager.isContainerAlive).mockReturnValue("running" as any);
1520
+ deps.launcher.getSession
1521
+ .mockReturnValueOnce({ archived: false } as any) // check archived
1522
+ .mockReturnValueOnce({ state: "starting", containerId: "cid-abc", pid: 99999 } as any); // after grace
1523
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1524
+ orchestrator.initialize();
1525
+
1526
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1527
+ await vi.advanceTimersByTimeAsync(15_000);
1528
+ await vi.advanceTimersByTimeAsync(0);
1529
+
1530
+ expect(containerManager.isContainerAlive).toHaveBeenCalledWith("cid-abc");
1531
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1532
+ });
1533
+
1534
+ it("relaunches exited containerized session even when container was removed", async () => {
1535
+ // If a container was removed externally (e.g. docker prune), the session
1536
+ // state becomes "exited". The fix skips PID/container checks for exited
1537
+ // sessions entirely, so relaunch proceeds.
1538
+ vi.mocked(containerManager.isContainerAlive).mockReturnValue("not_found" as any);
1539
+ deps.launcher.getSession
1540
+ .mockReturnValueOnce({ archived: false } as any) // check archived
1541
+ .mockReturnValueOnce({ state: "exited", containerId: "cid-dead", pid: 99999 } as any); // after grace
1542
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1543
+ orchestrator.initialize();
1544
+
1545
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1546
+ await vi.advanceTimersByTimeAsync(15_000);
1547
+ await vi.advanceTimersByTimeAsync(0);
1548
+
1549
+ // Exited sessions skip the container/PID check entirely, so relaunch proceeds
1550
+ expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
1551
+ });
1552
+
1553
+ it("relaunches when CLI does not reconnect after grace period", async () => {
1554
+ // When CLI disconnects and doesn't reconnect, the session should be relaunched.
1555
+ deps.launcher.getSession
1556
+ .mockReturnValueOnce({ archived: false } as any) // First call: check archived
1557
+ .mockReturnValueOnce({ state: "exited", pid: undefined } as any); // Second call: after grace
1558
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1559
+ orchestrator.initialize();
1560
+
1561
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1562
+ // Advance past grace (10s) + cooldown (5s) and flush microtasks
1563
+ await vi.advanceTimersByTimeAsync(15_000);
1564
+ await vi.advanceTimersByTimeAsync(0);
1565
+
1566
+ expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
1567
+ });
1568
+
1569
+ it("preserves retry budget when relaunch returns ok:false without error", async () => {
1570
+ // A silent failure (ok:false, no error string) should NOT reset the auto-relaunch
1571
+ // count. This prevents unlimited retries when the launcher silently fails.
1572
+ deps.launcher.getSession.mockReturnValue({ archived: false, state: "exited", pid: undefined } as any);
1573
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1574
+ deps.launcher.relaunch.mockResolvedValue({ ok: false }); // no error string
1575
+ orchestrator.initialize();
1576
+
1577
+ // Trigger 3 silent-failure relaunches (the max)
1578
+ for (let i = 0; i < 3; i++) {
1579
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1580
+ await vi.advanceTimersByTimeAsync(15_000);
1581
+ await vi.advanceTimersByTimeAsync(0);
1582
+ }
1583
+
1584
+ // 4th attempt should hit the MAX_AUTO_RELAUNCHES limit
1585
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1586
+ await vi.advanceTimersByTimeAsync(15_000);
1587
+ await vi.advanceTimersByTimeAsync(0);
1588
+
1589
+ // Only 3 relaunch calls, 4th was rejected at the limit
1590
+ expect(deps.launcher.relaunch).toHaveBeenCalledTimes(3);
1591
+ });
1592
+
1593
+ it("stops after MAX_AUTO_RELAUNCHES attempts", async () => {
1594
+ // After reaching the max auto-relaunch count, give up and notify the user.
1595
+ // Mock relaunch to return an error so the count doesn't get cleared
1596
+ // (successful relaunch clears the count, simulating recovery).
1597
+ deps.launcher.getSession.mockReturnValue({ archived: false, state: "exited", pid: undefined } as any);
1598
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1599
+ deps.launcher.relaunch.mockResolvedValue({ ok: false, error: "crashed again" });
1600
+ orchestrator.initialize();
1601
+
1602
+ // Trigger 3 relaunches (the max). Each needs the relaunchingSet cooldown
1603
+ // to clear before the next attempt can proceed.
1604
+ for (let i = 0; i < 3; i++) {
1605
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1606
+ await vi.advanceTimersByTimeAsync(15_000);
1607
+ await vi.advanceTimersByTimeAsync(0);
1608
+ }
1609
+
1610
+ // 4th attempt should be rejected since count reached MAX_AUTO_RELAUNCHES
1611
+ companionBus.emit("session:relaunch-needed", { sessionId: "s1" });
1612
+ await vi.advanceTimersByTimeAsync(15_000);
1613
+ await vi.advanceTimersByTimeAsync(0);
1614
+
1615
+ // relaunch should have been called 3 times, not 4
1616
+ expect(deps.launcher.relaunch).toHaveBeenCalledTimes(3);
1617
+ // Should broadcast error message to session
1618
+ expect(deps.wsBridge.broadcastToSession).toHaveBeenCalledWith("s1", expect.objectContaining({
1619
+ type: "error",
1620
+ message: expect.stringContaining("keeps crashing"),
1621
+ }));
1622
+ });
1623
+ });
1624
+
1625
+ // ── Proactive keepalive ───────────────────────────────────────────────────
1626
+
1627
+ describe("proactive keepalive (auto-relaunch on exit without browser)", () => {
1628
+ beforeEach(() => {
1629
+ vi.useFakeTimers();
1630
+ });
1631
+
1632
+ afterEach(() => {
1633
+ vi.useRealTimers();
1634
+ });
1635
+
1636
+ it("schedules relaunch when CLI exits unexpectedly", async () => {
1637
+ // When a CLI process exits (crash) and is not an intentional kill,
1638
+ // the orchestrator should proactively relaunch it after a short delay
1639
+ // even if no browsers are connected.
1640
+ deps.launcher.getSession.mockReturnValue({
1641
+ archived: false,
1642
+ state: "exited",
1643
+ pid: undefined,
1644
+ } as any);
1645
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1646
+ orchestrator.initialize();
1647
+
1648
+ // Simulate CLI exit
1649
+ companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
1650
+
1651
+ // Advance past keepalive delay (3s) + relaunch grace (10s) + cooldown
1652
+ await vi.advanceTimersByTimeAsync(3_000);
1653
+ await vi.advanceTimersByTimeAsync(15_000);
1654
+ await vi.advanceTimersByTimeAsync(0);
1655
+
1656
+ expect(deps.launcher.relaunch).toHaveBeenCalledWith("s1");
1657
+ });
1658
+
1659
+ it("does NOT proactively relaunch after idle-kill (intentional kill)", async () => {
1660
+ // Idle-kill is intentional — the proactive keepalive should NOT trigger.
1661
+ // The debounce timer in ws-bridge is also cancelled by the idle-kill
1662
+ // handler (via cancelDisconnectTimer), so session:relaunch-needed never
1663
+ // fires from the debounce path. A browser reconnect CAN still relaunch.
1664
+ deps.launcher.getSession.mockReturnValue({
1665
+ archived: false,
1666
+ state: "exited",
1667
+ pid: undefined,
1668
+ } as any);
1669
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1670
+ orchestrator.initialize();
1671
+
1672
+ // Simulate idle-kill followed by session exit
1673
+ companionBus.emit("session:idle-kill", { sessionId: "s1" });
1674
+ companionBus.emit("session:exited", { sessionId: "s1", exitCode: 0 });
1675
+
1676
+ // Advance well past any possible keepalive delay
1677
+ await vi.advanceTimersByTimeAsync(30_000);
1678
+ await vi.advanceTimersByTimeAsync(0);
1679
+
1680
+ // Proactive keepalive should NOT have relaunched
1681
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1682
+ // Disconnect debounce timer should have been cancelled
1683
+ expect(deps.wsBridge.cancelDisconnectTimer).toHaveBeenCalledWith("s1");
1684
+ });
1685
+
1686
+ it("does NOT relaunch archived sessions", async () => {
1687
+ // Archived sessions should not be relaunched proactively.
1688
+ deps.launcher.getSession.mockReturnValue({
1689
+ archived: true,
1690
+ state: "exited",
1691
+ pid: undefined,
1692
+ } as any);
1693
+ orchestrator.initialize();
1694
+
1695
+ companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
1696
+
1697
+ await vi.advanceTimersByTimeAsync(30_000);
1698
+ await vi.advanceTimersByTimeAsync(0);
1699
+
1700
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1701
+ });
1702
+
1703
+ it("uses exponential backoff on repeated crashes (3s → 6s → 12s)", async () => {
1704
+ // Each crash should increase the delay before the keepalive timer fires.
1705
+ let relaunchCount = 0;
1706
+ deps.launcher.getSession.mockReturnValue({
1707
+ archived: false,
1708
+ state: "exited",
1709
+ pid: undefined,
1710
+ } as any);
1711
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1712
+ // Simulate repeated failures so the auto-relaunch count increments
1713
+ deps.launcher.relaunch.mockImplementation(async () => {
1714
+ relaunchCount++;
1715
+ return { ok: false, error: "crashed" };
1716
+ });
1717
+ orchestrator.initialize();
1718
+
1719
+ // ── 1st crash: 3s keepalive delay ──
1720
+ companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
1721
+
1722
+ // At 2s: nothing yet (3s delay not elapsed)
1723
+ await vi.advanceTimersByTimeAsync(2_000);
1724
+ expect(relaunchCount).toBe(0);
1725
+
1726
+ // At 3s: keepalive fires → handleAutoRelaunch with 10s grace
1727
+ await vi.advanceTimersByTimeAsync(1_000);
1728
+ await vi.advanceTimersByTimeAsync(15_000);
1729
+ await vi.advanceTimersByTimeAsync(0);
1730
+ expect(relaunchCount).toBe(1);
1731
+
1732
+ // ── 2nd crash: 6s keepalive delay ──
1733
+ companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
1734
+
1735
+ // At 5s: nothing yet (6s delay not elapsed)
1736
+ await vi.advanceTimersByTimeAsync(5_000);
1737
+ expect(relaunchCount).toBe(1);
1738
+
1739
+ // At 6s: keepalive fires → handleAutoRelaunch with 10s grace
1740
+ await vi.advanceTimersByTimeAsync(1_000);
1741
+ await vi.advanceTimersByTimeAsync(15_000);
1742
+ await vi.advanceTimersByTimeAsync(0);
1743
+ expect(relaunchCount).toBe(2);
1744
+
1745
+ // ── 3rd crash: 12s keepalive delay ──
1746
+ companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
1747
+
1748
+ // At 11s: nothing yet (12s delay not elapsed)
1749
+ await vi.advanceTimersByTimeAsync(11_000);
1750
+ expect(relaunchCount).toBe(2);
1751
+
1752
+ // At 12s: keepalive fires → handleAutoRelaunch with 10s grace
1753
+ await vi.advanceTimersByTimeAsync(1_000);
1754
+ await vi.advanceTimersByTimeAsync(15_000);
1755
+ await vi.advanceTimersByTimeAsync(0);
1756
+ expect(relaunchCount).toBe(3);
1757
+ });
1758
+
1759
+ it("cancels keepalive timer on session delete", async () => {
1760
+ // If user deletes a session while a keepalive timer is pending,
1761
+ // the timer should be cancelled and no relaunch should occur.
1762
+ deps.launcher.getSession.mockReturnValue({
1763
+ archived: false,
1764
+ state: "exited",
1765
+ pid: undefined,
1766
+ } as any);
1767
+ deps.wsBridge.isCliConnected.mockReturnValue(false);
1768
+ orchestrator.initialize();
1769
+
1770
+ // Simulate CLI exit
1771
+ companionBus.emit("session:exited", { sessionId: "s1", exitCode: 1 });
1772
+
1773
+ // Delete the session before the keepalive timer fires
1774
+ await orchestrator.deleteSession("s1");
1775
+
1776
+ // Advance past all delays
1777
+ await vi.advanceTimersByTimeAsync(30_000);
1778
+ await vi.advanceTimersByTimeAsync(0);
1779
+
1780
+ // kill() is called by deleteSession, but relaunch should NOT be
1781
+ expect(deps.launcher.relaunch).not.toHaveBeenCalled();
1782
+ });
1783
+ });
1784
+ });