@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,1108 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { AgentConfig, AgentExecution } from "./agent-types.js";
3
+ import type { SdkSessionInfo } from "./cli-launcher.js";
4
+
5
+ // ─── Hoisted mocks ──────────────────────────────────────────────────────────
6
+ // These must be hoisted so vi.mock() factory functions can reference them.
7
+
8
+ // We need a mock that works with `new Cron(...)`. Vitest requires a real
9
+ // class/function for `new` calls. We track all constructor calls and
10
+ // instances so tests can inspect them.
11
+ const mockCronState = vi.hoisted(() => ({
12
+ constructorCalls: [] as Array<{ args: unknown[] }>,
13
+ instances: [] as Array<{ stop: ReturnType<typeof vi.fn>; nextRun: ReturnType<typeof vi.fn> }>,
14
+ }));
15
+
16
+ const MockCronClass = vi.hoisted(() => {
17
+ return class MockCron {
18
+ stop = vi.fn();
19
+ nextRun = vi.fn();
20
+ constructor(...args: unknown[]) {
21
+ mockCronState.constructorCalls.push({ args });
22
+ mockCronState.instances.push(this);
23
+ }
24
+ };
25
+ });
26
+
27
+ const mockAgentStore = vi.hoisted(() => ({
28
+ listAgents: vi.fn<() => AgentConfig[]>().mockReturnValue([]),
29
+ getAgent: vi.fn<(id: string) => AgentConfig | null>().mockReturnValue(null),
30
+ updateAgent: vi.fn<(id: string, updates: Partial<AgentConfig>) => AgentConfig | null>().mockReturnValue(null),
31
+ }));
32
+
33
+ const mockEnvManager = vi.hoisted(() => ({
34
+ getEnv: vi.fn().mockReturnValue(null),
35
+ }));
36
+
37
+ const mockSessionNames = vi.hoisted(() => ({
38
+ setName: vi.fn(),
39
+ }));
40
+
41
+ const mockExecutionStoreInstance = vi.hoisted(() => ({
42
+ append: vi.fn(),
43
+ update: vi.fn(),
44
+ list: vi.fn().mockReturnValue({ executions: [], total: 0 }),
45
+ }));
46
+
47
+ // Use a proper class so `new ExecutionStore()` works correctly.
48
+ const MockExecutionStoreClass = vi.hoisted(() => {
49
+ return class MockExecutionStore {
50
+ append = mockExecutionStoreInstance.append;
51
+ update = mockExecutionStoreInstance.update;
52
+ list = mockExecutionStoreInstance.list;
53
+ };
54
+ });
55
+
56
+ // ─── vi.mock() calls ────────────────────────────────────────────────────────
57
+
58
+ vi.mock("croner", () => ({
59
+ Cron: MockCronClass,
60
+ }));
61
+
62
+ vi.mock("./agent-store.js", () => mockAgentStore);
63
+
64
+ vi.mock("./env-manager.js", () => mockEnvManager);
65
+
66
+ vi.mock("./session-names.js", () => mockSessionNames);
67
+
68
+ vi.mock("./execution-store.js", () => ({
69
+ ExecutionStore: MockExecutionStoreClass,
70
+ }));
71
+
72
+ // Mock mkdtempSync to avoid filesystem side effects in tests.
73
+ // The agent-executor uses it for "temp" cwd.
74
+ vi.mock("node:fs", async (importOriginal) => {
75
+ const actual = await importOriginal<typeof import("node:fs")>();
76
+ return {
77
+ ...actual,
78
+ mkdtempSync: vi.fn().mockReturnValue("/tmp/companion-agent-test-abc123"),
79
+ };
80
+ });
81
+
82
+ // ─── Import the class under test (after mocks are set up) ───────────────────
83
+
84
+ import { AgentExecutor } from "./agent-executor.js";
85
+
86
+ // ─── Helpers ────────────────────────────────────────────────────────────────
87
+
88
+ /** Build a minimal AgentConfig with sensible defaults. Override as needed. */
89
+ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
90
+ return {
91
+ id: "test-agent",
92
+ version: 1,
93
+ name: "Test Agent",
94
+ description: "A test agent",
95
+ backendType: "claude",
96
+ model: "claude-sonnet-4-6",
97
+ permissionMode: "bypassPermissions",
98
+ cwd: "/tmp/test-repo",
99
+ prompt: "Do something useful",
100
+ enabled: true,
101
+ createdAt: Date.now(),
102
+ updatedAt: Date.now(),
103
+ totalRuns: 0,
104
+ consecutiveFailures: 0,
105
+ ...overrides,
106
+ };
107
+ }
108
+
109
+ /** Create a mock CliLauncher with the methods AgentExecutor uses. */
110
+ function makeMockLauncher() {
111
+ return {
112
+ launch: vi.fn<(opts: Record<string, unknown>) => SdkSessionInfo>().mockImplementation((opts) => ({
113
+ sessionId: "session-123",
114
+ state: "starting" as const,
115
+ cwd: (opts?.cwd as string) || "/tmp",
116
+ createdAt: Date.now(),
117
+ })),
118
+ isAlive: vi.fn<(id: string) => boolean>().mockReturnValue(false),
119
+ getSession: vi.fn<(id: string) => SdkSessionInfo | undefined>().mockReturnValue({
120
+ sessionId: "session-123",
121
+ state: "connected",
122
+ cwd: "/tmp",
123
+ createdAt: Date.now(),
124
+ }),
125
+ };
126
+ }
127
+
128
+ /** Create a mock WsBridge with the methods AgentExecutor uses. */
129
+ function makeMockWsBridge() {
130
+ return {
131
+ injectMcpSetServers: vi.fn(),
132
+ injectSystemPrompt: vi.fn(),
133
+ injectUserMessage: vi.fn(),
134
+ };
135
+ }
136
+
137
+ /** Helper to get the most recently created Cron mock instance. */
138
+ function getLastCronInstance() {
139
+ return mockCronState.instances[mockCronState.instances.length - 1];
140
+ }
141
+
142
+ // ─── Tests ──────────────────────────────────────────────────────────────────
143
+
144
+ describe("AgentExecutor", () => {
145
+ let launcher: ReturnType<typeof makeMockLauncher>;
146
+ let wsBridge: ReturnType<typeof makeMockWsBridge>;
147
+ let executor: AgentExecutor;
148
+
149
+ beforeEach(() => {
150
+ vi.clearAllMocks();
151
+ // Reset Cron tracking state between tests
152
+ mockCronState.constructorCalls.length = 0;
153
+ mockCronState.instances.length = 0;
154
+ // Use fake timers so we can control setTimeout/setInterval in
155
+ // waitForCLIConnection without actually waiting.
156
+ vi.useFakeTimers();
157
+
158
+ launcher = makeMockLauncher();
159
+ wsBridge = makeMockWsBridge();
160
+ executor = new AgentExecutor(launcher as never, wsBridge as never);
161
+ });
162
+
163
+ afterEach(() => {
164
+ executor.destroy();
165
+ vi.useRealTimers();
166
+ });
167
+
168
+ // =========================================================================
169
+ // startAll
170
+ // =========================================================================
171
+ describe("startAll", () => {
172
+ it("loads agents from disk and schedules enabled ones with schedule triggers", () => {
173
+ // Three agents: one enabled with schedule, one disabled, one without schedule.
174
+ // Only the enabled agent with a schedule trigger should get a Cron timer.
175
+ const enabledAgent = makeAgent({
176
+ id: "cron-agent",
177
+ name: "Cron Agent",
178
+ enabled: true,
179
+ triggers: { schedule: { enabled: true, expression: "*/5 * * * *", recurring: true } },
180
+ });
181
+ const disabledAgent = makeAgent({
182
+ id: "off-agent",
183
+ name: "Off Agent",
184
+ enabled: false,
185
+ });
186
+ const noScheduleAgent = makeAgent({
187
+ id: "no-schedule",
188
+ name: "No Schedule",
189
+ enabled: true,
190
+ // No schedule trigger
191
+ });
192
+
193
+ mockAgentStore.listAgents.mockReturnValue([enabledAgent, disabledAgent, noScheduleAgent]);
194
+
195
+ executor.startAll();
196
+
197
+ // listAgents should be called once
198
+ expect(mockAgentStore.listAgents).toHaveBeenCalledOnce();
199
+ // Cron constructor should have been called once (only for the enabled scheduled agent)
200
+ expect(mockCronState.constructorCalls).toHaveLength(1);
201
+ // The first argument to Cron should be the cron expression
202
+ expect(mockCronState.constructorCalls[0].args[0]).toBe("*/5 * * * *");
203
+ });
204
+
205
+ it("does nothing when no agents exist", () => {
206
+ mockAgentStore.listAgents.mockReturnValue([]);
207
+
208
+ executor.startAll();
209
+
210
+ expect(mockCronState.constructorCalls).toHaveLength(0);
211
+ });
212
+ });
213
+
214
+ // =========================================================================
215
+ // scheduleAgent
216
+ // =========================================================================
217
+ describe("scheduleAgent", () => {
218
+ it("creates a Cron timer for recurring agents", () => {
219
+ const agent = makeAgent({
220
+ id: "recurring-agent",
221
+ name: "Recurring Agent",
222
+ enabled: true,
223
+ triggers: { schedule: { enabled: true, expression: "0 8 * * *", recurring: true } },
224
+ });
225
+
226
+ executor.scheduleAgent(agent);
227
+
228
+ // Cron should be created with the expression
229
+ expect(mockCronState.constructorCalls).toHaveLength(1);
230
+ expect(mockCronState.constructorCalls[0].args[0]).toBe("0 8 * * *");
231
+ // The third argument should be the callback function (for recurring)
232
+ expect(typeof mockCronState.constructorCalls[0].args[2]).toBe("function");
233
+ });
234
+
235
+ it("skips disabled agents", () => {
236
+ const agent = makeAgent({
237
+ id: "disabled-agent",
238
+ enabled: false,
239
+ triggers: { schedule: { enabled: true, expression: "0 8 * * *", recurring: true } },
240
+ });
241
+
242
+ executor.scheduleAgent(agent);
243
+
244
+ // Cron should NOT be created
245
+ expect(mockCronState.constructorCalls).toHaveLength(0);
246
+ });
247
+
248
+ it("skips agents with disabled schedule trigger", () => {
249
+ const agent = makeAgent({
250
+ id: "disabled-schedule",
251
+ enabled: true,
252
+ triggers: { schedule: { enabled: false, expression: "0 8 * * *", recurring: true } },
253
+ });
254
+
255
+ executor.scheduleAgent(agent);
256
+
257
+ expect(mockCronState.constructorCalls).toHaveLength(0);
258
+ });
259
+
260
+ it("skips agents with no schedule expression", () => {
261
+ const agent = makeAgent({
262
+ id: "no-expression",
263
+ enabled: true,
264
+ triggers: { schedule: { enabled: true, expression: "", recurring: true } },
265
+ });
266
+
267
+ executor.scheduleAgent(agent);
268
+
269
+ expect(mockCronState.constructorCalls).toHaveLength(0);
270
+ });
271
+
272
+ it("stops existing timer before rescheduling", () => {
273
+ const agent = makeAgent({
274
+ id: "reschedule-me",
275
+ enabled: true,
276
+ triggers: { schedule: { enabled: true, expression: "0 * * * *", recurring: true } },
277
+ });
278
+
279
+ // Schedule once
280
+ executor.scheduleAgent(agent);
281
+ expect(mockCronState.instances).toHaveLength(1);
282
+ const firstInstance = mockCronState.instances[0];
283
+
284
+ // Schedule again -- should stop the old timer first, then create a new one
285
+ executor.scheduleAgent(agent);
286
+ expect(firstInstance.stop).toHaveBeenCalledTimes(1);
287
+ expect(mockCronState.instances).toHaveLength(2);
288
+ });
289
+
290
+ it("creates a one-shot Cron for non-recurring agents with future date", () => {
291
+ const futureDate = new Date(Date.now() + 60_000).toISOString();
292
+ const agent = makeAgent({
293
+ id: "one-shot",
294
+ enabled: true,
295
+ triggers: { schedule: { enabled: true, expression: futureDate, recurring: false } },
296
+ });
297
+
298
+ executor.scheduleAgent(agent);
299
+
300
+ // Cron should be created with a Date object (one-shot)
301
+ expect(mockCronState.constructorCalls).toHaveLength(1);
302
+ // First arg should be a Date for one-shot
303
+ const firstArg = mockCronState.constructorCalls[0].args[0];
304
+ expect(firstArg).toBeInstanceOf(Date);
305
+ });
306
+
307
+ it("skips one-shot agent when target time is in the past", () => {
308
+ const pastDate = new Date(Date.now() - 60_000).toISOString();
309
+ const agent = makeAgent({
310
+ id: "past-one-shot",
311
+ enabled: true,
312
+ triggers: { schedule: { enabled: true, expression: pastDate, recurring: false } },
313
+ });
314
+
315
+ executor.scheduleAgent(agent);
316
+
317
+ // Cron should NOT be created for a past date
318
+ expect(mockCronState.constructorCalls).toHaveLength(0);
319
+ });
320
+ });
321
+
322
+ // =========================================================================
323
+ // stopAgent
324
+ // =========================================================================
325
+ describe("stopAgent", () => {
326
+ it("stops and removes the timer for a scheduled agent", () => {
327
+ const agent = makeAgent({
328
+ id: "stop-me",
329
+ enabled: true,
330
+ triggers: { schedule: { enabled: true, expression: "0 8 * * *", recurring: true } },
331
+ });
332
+
333
+ executor.scheduleAgent(agent);
334
+ const cronInstance = getLastCronInstance();
335
+
336
+ executor.stopAgent("stop-me");
337
+
338
+ expect(cronInstance.stop).toHaveBeenCalledOnce();
339
+ // After stopping, getNextRunTime should return null (timer removed)
340
+ expect(executor.getNextRunTime("stop-me")).toBeNull();
341
+ });
342
+
343
+ it("does nothing when agent has no timer", () => {
344
+ // Should not throw or have side effects
345
+ executor.stopAgent("nonexistent-agent");
346
+ // No Cron instances should have been created at all
347
+ expect(mockCronState.instances).toHaveLength(0);
348
+ });
349
+ });
350
+
351
+ // =========================================================================
352
+ // executeAgent -- full flow
353
+ // =========================================================================
354
+ describe("executeAgent", () => {
355
+ it("full flow: creates session, waits for CLI, sends prompt, tracks execution", async () => {
356
+ const agent = makeAgent({
357
+ id: "exec-agent",
358
+ name: "Exec Agent",
359
+ enabled: true,
360
+ prompt: "Run the tests",
361
+ cwd: "/my/project",
362
+ });
363
+ mockAgentStore.getAgent.mockReturnValue(agent);
364
+
365
+ const result = await executor.executeAgent("exec-agent");
366
+
367
+ // Should have launched a session
368
+ expect(launcher.launch).toHaveBeenCalledOnce();
369
+ expect(launcher.launch).toHaveBeenCalledWith(
370
+ expect.objectContaining({
371
+ model: "claude-sonnet-4-6",
372
+ permissionMode: "bypassPermissions",
373
+ cwd: "/my/project",
374
+ }),
375
+ );
376
+
377
+ // Should set the session name
378
+ expect(mockSessionNames.setName).toHaveBeenCalledWith(
379
+ "session-123",
380
+ expect.stringContaining("Exec Agent"),
381
+ );
382
+
383
+ // Should inject the user message with agent prefix
384
+ expect(wsBridge.injectUserMessage).toHaveBeenCalledOnce();
385
+ const sentPrompt = wsBridge.injectUserMessage.mock.calls[0][1] as string;
386
+ expect(sentPrompt).toContain("[agent:exec-agent Exec Agent]");
387
+ expect(sentPrompt).toContain("Run the tests");
388
+
389
+ // Should update agent tracking (lastRunAt, totalRuns, etc.)
390
+ expect(mockAgentStore.updateAgent).toHaveBeenCalledWith("exec-agent", expect.objectContaining({
391
+ lastRunAt: expect.any(Number),
392
+ lastSessionId: "session-123",
393
+ totalRuns: 1,
394
+ consecutiveFailures: 0,
395
+ }));
396
+
397
+ // Should persist execution to the ExecutionStore
398
+ expect(mockExecutionStoreInstance.append).toHaveBeenCalledOnce();
399
+ const appendedExec = mockExecutionStoreInstance.append.mock.calls[0][0] as AgentExecution;
400
+ expect(appendedExec.agentId).toBe("exec-agent");
401
+ expect(appendedExec.sessionId).toBe("session-123");
402
+ expect(appendedExec.triggerType).toBe("manual");
403
+ expect(appendedExec.startedAt).toBeGreaterThan(0);
404
+
405
+ // Return value should be the session info
406
+ expect(result).toBeDefined();
407
+ expect(result!.sessionId).toBe("session-123");
408
+ });
409
+
410
+ it("skips when agent is not found", async () => {
411
+ // getAgent returns null by default -- agent does not exist
412
+ mockAgentStore.getAgent.mockReturnValue(null);
413
+
414
+ const result = await executor.executeAgent("nonexistent");
415
+
416
+ expect(result).toBeUndefined();
417
+ expect(launcher.launch).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("skips when agent is disabled and force is not set", async () => {
421
+ const agent = makeAgent({ id: "disabled", enabled: false });
422
+ mockAgentStore.getAgent.mockReturnValue(agent);
423
+
424
+ const result = await executor.executeAgent("disabled");
425
+
426
+ expect(result).toBeUndefined();
427
+ expect(launcher.launch).not.toHaveBeenCalled();
428
+ });
429
+
430
+ it("runs disabled agent when force=true", async () => {
431
+ // Even though agent.enabled is false, force=true should bypass the check
432
+ const agent = makeAgent({ id: "disabled-force", enabled: false, prompt: "forced run" });
433
+ mockAgentStore.getAgent.mockReturnValue(agent);
434
+
435
+ const result = await executor.executeAgent("disabled-force", undefined, { force: true });
436
+
437
+ expect(result).toBeDefined();
438
+ expect(launcher.launch).toHaveBeenCalledOnce();
439
+ });
440
+
441
+ it("skips when previous execution is still running (overlap prevention)", async () => {
442
+ // Simulate an agent whose previous session is still alive
443
+ const agent = makeAgent({
444
+ id: "overlapping",
445
+ lastSessionId: "still-running-session",
446
+ });
447
+ mockAgentStore.getAgent.mockReturnValue(agent);
448
+ // isAlive returns true for the previous session
449
+ launcher.isAlive.mockReturnValue(true);
450
+
451
+ const result = await executor.executeAgent("overlapping");
452
+
453
+ expect(result).toBeUndefined();
454
+ expect(launcher.launch).not.toHaveBeenCalled();
455
+ });
456
+
457
+ it("handles errors: marks execution as failed, increments consecutiveFailures", async () => {
458
+ const agent = makeAgent({
459
+ id: "fail-agent",
460
+ name: "Fail Agent",
461
+ consecutiveFailures: 1,
462
+ });
463
+ mockAgentStore.getAgent.mockReturnValue(agent);
464
+
465
+ // Make launch throw an error
466
+ launcher.launch.mockImplementation(() => {
467
+ throw new Error("CLI binary not found");
468
+ });
469
+
470
+ const result = await executor.executeAgent("fail-agent");
471
+
472
+ expect(result).toBeUndefined();
473
+
474
+ // Execution should be recorded with error
475
+ expect(mockExecutionStoreInstance.append).toHaveBeenCalledOnce();
476
+ const appendedExec = mockExecutionStoreInstance.append.mock.calls[0][0] as AgentExecution;
477
+ expect(appendedExec.error).toBe("CLI binary not found");
478
+ expect(appendedExec.completedAt).toBeGreaterThan(0);
479
+
480
+ // consecutiveFailures should be incremented from 1 to 2
481
+ expect(mockAgentStore.updateAgent).toHaveBeenCalledWith("fail-agent", expect.objectContaining({
482
+ consecutiveFailures: 2,
483
+ lastRunAt: expect.any(Number),
484
+ }));
485
+ });
486
+
487
+ it("auto-disables agent after MAX_CONSECUTIVE_FAILURES (5)", async () => {
488
+ // Agent already has 4 consecutive failures -- one more triggers auto-disable
489
+ const agent = makeAgent({
490
+ id: "auto-disable",
491
+ name: "Auto Disable Agent",
492
+ consecutiveFailures: 4,
493
+ });
494
+ mockAgentStore.getAgent.mockReturnValue(agent);
495
+
496
+ launcher.launch.mockImplementation(() => {
497
+ throw new Error("repeated failure");
498
+ });
499
+
500
+ await executor.executeAgent("auto-disable");
501
+
502
+ // Should update agent with enabled=false and consecutiveFailures=5
503
+ expect(mockAgentStore.updateAgent).toHaveBeenCalledWith("auto-disable", expect.objectContaining({
504
+ enabled: false,
505
+ consecutiveFailures: 5,
506
+ }));
507
+ });
508
+
509
+ it("does not auto-disable when failures are below threshold", async () => {
510
+ // After this failure: consecutiveFailures = 3, below the threshold of 5
511
+ const agent = makeAgent({
512
+ id: "below-threshold",
513
+ consecutiveFailures: 2,
514
+ });
515
+ mockAgentStore.getAgent.mockReturnValue(agent);
516
+
517
+ launcher.launch.mockImplementation(() => {
518
+ throw new Error("temporary failure");
519
+ });
520
+
521
+ await executor.executeAgent("below-threshold");
522
+
523
+ // Should NOT include enabled=false in the update
524
+ const updateCall = mockAgentStore.updateAgent.mock.calls[0];
525
+ const updates = updateCall[1] as Partial<AgentConfig>;
526
+ expect(updates.enabled).toBeUndefined();
527
+ expect(updates.consecutiveFailures).toBe(3);
528
+ });
529
+
530
+ it("replaces {{input}} in prompt with provided input", async () => {
531
+ const agent = makeAgent({
532
+ id: "input-agent",
533
+ prompt: "Process this PR: {{input}}",
534
+ });
535
+ mockAgentStore.getAgent.mockReturnValue(agent);
536
+
537
+ await executor.executeAgent("input-agent", "https://github.com/org/repo/pull/42");
538
+
539
+ const sentPrompt = wsBridge.injectUserMessage.mock.calls[0][1] as string;
540
+ expect(sentPrompt).toContain("Process this PR: https://github.com/org/repo/pull/42");
541
+ expect(sentPrompt).not.toContain("{{input}}");
542
+ });
543
+
544
+ it("strips {{input}} placeholder when no input is provided", async () => {
545
+ const agent = makeAgent({
546
+ id: "strip-input-agent",
547
+ prompt: "Run task: {{input}} now",
548
+ });
549
+ mockAgentStore.getAgent.mockReturnValue(agent);
550
+
551
+ await executor.executeAgent("strip-input-agent");
552
+
553
+ const sentPrompt = wsBridge.injectUserMessage.mock.calls[0][1] as string;
554
+ expect(sentPrompt).toContain("Run task: now");
555
+ expect(sentPrompt).not.toContain("{{input}}");
556
+ });
557
+
558
+ it("resolves environment variables from envSlug and inline env", async () => {
559
+ // Agent uses both an envSlug (resolved via envManager) and inline env vars.
560
+ // The inline env should override envSlug vars if they overlap.
561
+ const agent = makeAgent({
562
+ id: "env-agent",
563
+ envSlug: "prod-env",
564
+ env: { INLINE_VAR: "inline-value" },
565
+ });
566
+ mockAgentStore.getAgent.mockReturnValue(agent);
567
+ mockEnvManager.getEnv.mockReturnValue({
568
+ name: "Production",
569
+ slug: "prod-env",
570
+ variables: { ENV_VAR: "env-value" },
571
+ });
572
+
573
+ await executor.executeAgent("env-agent");
574
+
575
+ // launch should be called with merged env vars (envSlug + inline)
576
+ expect(launcher.launch).toHaveBeenCalledWith(
577
+ expect.objectContaining({
578
+ env: { ENV_VAR: "env-value", INLINE_VAR: "inline-value" },
579
+ }),
580
+ );
581
+ });
582
+
583
+ it("merges additional env vars and injects a Claude system prompt when provided", async () => {
584
+ const agent = makeAgent({
585
+ id: "linear-agent",
586
+ name: "Linear Agent",
587
+ backendType: "claude",
588
+ });
589
+ mockAgentStore.getAgent.mockReturnValue(agent);
590
+
591
+ await executor.executeAgent("linear-agent", "Handle this issue", {
592
+ triggerType: "linear",
593
+ additionalEnv: {
594
+ LINEAR_OAUTH_ACCESS_TOKEN: "lin_oauth_test",
595
+ LINEAR_API_KEY: "lin_oauth_test",
596
+ },
597
+ systemPrompt: "Use the Linear OAuth token for GraphQL requests.",
598
+ });
599
+
600
+ expect(launcher.launch).toHaveBeenCalledWith(
601
+ expect.objectContaining({
602
+ env: expect.objectContaining({
603
+ LINEAR_OAUTH_ACCESS_TOKEN: "lin_oauth_test",
604
+ LINEAR_API_KEY: "lin_oauth_test",
605
+ }),
606
+ }),
607
+ );
608
+ expect(wsBridge.injectSystemPrompt).toHaveBeenCalledWith(
609
+ "session-123",
610
+ "Use the Linear OAuth token for GraphQL requests.",
611
+ );
612
+ });
613
+
614
+ it("passes the extra system prompt directly to Codex launches", async () => {
615
+ const agent = makeAgent({
616
+ id: "linear-codex-agent",
617
+ name: "Linear Codex Agent",
618
+ backendType: "codex",
619
+ });
620
+ mockAgentStore.getAgent.mockReturnValue(agent);
621
+
622
+ await executor.executeAgent("linear-codex-agent", "Handle this issue", {
623
+ triggerType: "linear",
624
+ systemPrompt: "Codex linear context",
625
+ });
626
+
627
+ expect(launcher.launch).toHaveBeenCalledWith(
628
+ expect.objectContaining({
629
+ systemPrompt: "Codex linear context",
630
+ }),
631
+ );
632
+ expect(wsBridge.injectSystemPrompt).not.toHaveBeenCalled();
633
+ });
634
+
635
+ it("uses temp directory when cwd is 'temp'", async () => {
636
+ const agent = makeAgent({
637
+ id: "temp-cwd-agent",
638
+ cwd: "temp",
639
+ });
640
+ mockAgentStore.getAgent.mockReturnValue(agent);
641
+
642
+ await executor.executeAgent("temp-cwd-agent");
643
+
644
+ expect(launcher.launch).toHaveBeenCalledWith(
645
+ expect.objectContaining({
646
+ cwd: "/tmp/companion-agent-test-abc123",
647
+ }),
648
+ );
649
+ });
650
+
651
+ it("uses temp directory when cwd is empty", async () => {
652
+ const agent = makeAgent({
653
+ id: "empty-cwd-agent",
654
+ cwd: "",
655
+ });
656
+ mockAgentStore.getAgent.mockReturnValue(agent);
657
+
658
+ await executor.executeAgent("empty-cwd-agent");
659
+
660
+ expect(launcher.launch).toHaveBeenCalledWith(
661
+ expect.objectContaining({
662
+ cwd: "/tmp/companion-agent-test-abc123",
663
+ }),
664
+ );
665
+ });
666
+
667
+ it("configures MCP servers when specified", async () => {
668
+ const mcpServers = {
669
+ myServer: { type: "stdio" as const, command: "node", args: ["server.js"] },
670
+ };
671
+ const agent = makeAgent({
672
+ id: "mcp-agent",
673
+ mcpServers,
674
+ });
675
+ mockAgentStore.getAgent.mockReturnValue(agent);
676
+
677
+ // executeAgent has a 2s MCP_INIT_DELAY_MS setTimeout when mcpServers are set.
678
+ // We must advance fake timers to let it resolve.
679
+ const promise = executor.executeAgent("mcp-agent");
680
+ await vi.advanceTimersByTimeAsync(3000);
681
+ await promise;
682
+
683
+ // Should inject MCP servers before sending the prompt
684
+ expect(wsBridge.injectMcpSetServers).toHaveBeenCalledWith("session-123", mcpServers);
685
+ // Should still send the user message after MCP setup
686
+ expect(wsBridge.injectUserMessage).toHaveBeenCalled();
687
+ });
688
+
689
+ it("does not inject MCP servers when none are specified", async () => {
690
+ const agent = makeAgent({
691
+ id: "no-mcp-agent",
692
+ // No mcpServers
693
+ });
694
+ mockAgentStore.getAgent.mockReturnValue(agent);
695
+
696
+ await executor.executeAgent("no-mcp-agent");
697
+
698
+ expect(wsBridge.injectMcpSetServers).not.toHaveBeenCalled();
699
+ });
700
+
701
+ it("tags session with agentId and agentName", async () => {
702
+ const agent = makeAgent({
703
+ id: "tag-agent",
704
+ name: "Tag Agent",
705
+ });
706
+ mockAgentStore.getAgent.mockReturnValue(agent);
707
+
708
+ const result = await executor.executeAgent("tag-agent");
709
+
710
+ // The session info object is mutated in-place to include agent metadata
711
+ expect(result!.agentId).toBe("tag-agent");
712
+ expect(result!.agentName).toBe("Tag Agent");
713
+ });
714
+
715
+ it("uses 'schedule' triggerType when specified", async () => {
716
+ const agent = makeAgent({ id: "scheduled" });
717
+ mockAgentStore.getAgent.mockReturnValue(agent);
718
+
719
+ await executor.executeAgent("scheduled", undefined, { triggerType: "schedule" });
720
+
721
+ const appendedExec = mockExecutionStoreInstance.append.mock.calls[0][0] as AgentExecution;
722
+ expect(appendedExec.triggerType).toBe("schedule");
723
+ });
724
+ });
725
+
726
+ // =========================================================================
727
+ // waitForCLIConnection (tested indirectly via executeAgent)
728
+ // =========================================================================
729
+ describe("waitForCLIConnection (via executeAgent)", () => {
730
+ it("throws if CLI exits before connecting", async () => {
731
+ const agent = makeAgent({ id: "exit-early" });
732
+ mockAgentStore.getAgent.mockReturnValue(agent);
733
+
734
+ // After launch, getSession returns "exited" state on every poll
735
+ launcher.getSession.mockReturnValue({
736
+ sessionId: "session-123",
737
+ state: "exited",
738
+ exitCode: 1,
739
+ cwd: "/tmp",
740
+ createdAt: Date.now(),
741
+ });
742
+
743
+ const promise = executor.executeAgent("exit-early");
744
+ // Advance timers to trigger the poll
745
+ await vi.advanceTimersByTimeAsync(1000);
746
+
747
+ const result = await promise;
748
+ // Should have failed (error path in catch block)
749
+ expect(result).toBeUndefined();
750
+
751
+ // Execution should have error about CLI exiting before connecting
752
+ expect(mockExecutionStoreInstance.append).toHaveBeenCalledOnce();
753
+ const exec = mockExecutionStoreInstance.append.mock.calls[0][0] as AgentExecution;
754
+ expect(exec.error).toContain("CLI process exited before connecting");
755
+ });
756
+
757
+ it("throws if CLI does not connect within timeout", async () => {
758
+ const agent = makeAgent({ id: "timeout-agent" });
759
+ mockAgentStore.getAgent.mockReturnValue(agent);
760
+
761
+ // getSession always returns "starting" -- never transitions to connected
762
+ launcher.getSession.mockReturnValue({
763
+ sessionId: "session-123",
764
+ state: "starting",
765
+ cwd: "/tmp",
766
+ createdAt: Date.now(),
767
+ });
768
+
769
+ const promise = executor.executeAgent("timeout-agent");
770
+
771
+ // Advance past the 30s timeout (CLI_CONNECT_TIMEOUT_MS)
772
+ await vi.advanceTimersByTimeAsync(35_000);
773
+
774
+ const result = await promise;
775
+ expect(result).toBeUndefined();
776
+
777
+ // Should have a timeout error
778
+ expect(mockExecutionStoreInstance.append).toHaveBeenCalledOnce();
779
+ const exec = mockExecutionStoreInstance.append.mock.calls[0][0] as AgentExecution;
780
+ expect(exec.error).toContain("did not connect within");
781
+ });
782
+ });
783
+
784
+ // =========================================================================
785
+ // handleSessionExited
786
+ // =========================================================================
787
+ describe("handleSessionExited", () => {
788
+ it("marks execution as completed with exit code 0 (success)", async () => {
789
+ // First, create an execution by running an agent
790
+ const agent = makeAgent({ id: "exit-agent", name: "Exit Agent" });
791
+ mockAgentStore.getAgent.mockReturnValue(agent);
792
+
793
+ await executor.executeAgent("exit-agent");
794
+
795
+ // Now simulate the session exiting with code 0
796
+ executor.handleSessionExited("session-123", 0);
797
+
798
+ // executionStore.update should be called with success=true
799
+ expect(mockExecutionStoreInstance.update).toHaveBeenCalledWith("session-123", expect.objectContaining({
800
+ completedAt: expect.any(Number),
801
+ success: true,
802
+ }));
803
+
804
+ // In-memory execution should also be updated
805
+ const executions = executor.getExecutions("exit-agent");
806
+ expect(executions).toHaveLength(1);
807
+ expect(executions[0].completedAt).toBeGreaterThan(0);
808
+ expect(executions[0].success).toBe(true);
809
+ });
810
+
811
+ it("marks execution as failed with non-zero exit code", async () => {
812
+ const agent = makeAgent({ id: "fail-exit-agent" });
813
+ mockAgentStore.getAgent.mockReturnValue(agent);
814
+
815
+ await executor.executeAgent("fail-exit-agent");
816
+
817
+ executor.handleSessionExited("session-123", 1);
818
+
819
+ expect(mockExecutionStoreInstance.update).toHaveBeenCalledWith("session-123", expect.objectContaining({
820
+ completedAt: expect.any(Number),
821
+ success: false,
822
+ error: "Process exited with code 1",
823
+ }));
824
+
825
+ const executions = executor.getExecutions("fail-exit-agent");
826
+ expect(executions[0].success).toBe(false);
827
+ expect(executions[0].error).toContain("Process exited with code 1");
828
+ });
829
+
830
+ it("treats null exit code as success (e.g. signalled/normal termination)", async () => {
831
+ const agent = makeAgent({ id: "null-exit-agent" });
832
+ mockAgentStore.getAgent.mockReturnValue(agent);
833
+
834
+ await executor.executeAgent("null-exit-agent");
835
+
836
+ executor.handleSessionExited("session-123", null);
837
+
838
+ expect(mockExecutionStoreInstance.update).toHaveBeenCalledWith("session-123", expect.objectContaining({
839
+ success: true,
840
+ }));
841
+ });
842
+
843
+ it("does nothing for an unknown session", () => {
844
+ // No executions have been tracked, so this should be a no-op
845
+ executor.handleSessionExited("unknown-session-id", 0);
846
+
847
+ expect(mockExecutionStoreInstance.update).not.toHaveBeenCalled();
848
+ });
849
+
850
+ it("only marks the first matching incomplete execution", async () => {
851
+ const agent = makeAgent({ id: "multi-exec-agent" });
852
+ mockAgentStore.getAgent.mockReturnValue(agent);
853
+
854
+ // Run the agent once with session-aaa
855
+ launcher.launch.mockReturnValueOnce({
856
+ sessionId: "session-aaa",
857
+ state: "starting" as const,
858
+ cwd: "/tmp",
859
+ createdAt: Date.now(),
860
+ });
861
+ await executor.executeAgent("multi-exec-agent");
862
+
863
+ // Run the agent again with session-bbb
864
+ launcher.launch.mockReturnValueOnce({
865
+ sessionId: "session-bbb",
866
+ state: "starting" as const,
867
+ cwd: "/tmp",
868
+ createdAt: Date.now(),
869
+ });
870
+ // Need to reset isAlive to allow second execution
871
+ launcher.isAlive.mockReturnValue(false);
872
+ // Need to reset getAgent to match the updated lastSessionId
873
+ mockAgentStore.getAgent.mockReturnValue(
874
+ makeAgent({ id: "multi-exec-agent", lastSessionId: "session-aaa" }),
875
+ );
876
+ await executor.executeAgent("multi-exec-agent");
877
+
878
+ // Exit session-aaa
879
+ executor.handleSessionExited("session-aaa", 0);
880
+
881
+ const executions = executor.getExecutions("multi-exec-agent");
882
+ const aaa = executions.find((e) => e.sessionId === "session-aaa");
883
+ const bbb = executions.find((e) => e.sessionId === "session-bbb");
884
+ expect(aaa!.completedAt).toBeDefined();
885
+ expect(aaa!.success).toBe(true);
886
+ // session-bbb should still be running (no completedAt)
887
+ expect(bbb!.completedAt).toBeUndefined();
888
+ });
889
+ });
890
+
891
+ // =========================================================================
892
+ // executeAgentManually
893
+ // =========================================================================
894
+ describe("executeAgentManually", () => {
895
+ it("calls executeAgent with force=true and triggerType='manual'", async () => {
896
+ // Even though the agent is disabled, executeAgentManually should
897
+ // call executeAgent with force=true to bypass the enabled check.
898
+ const agent = makeAgent({ id: "manual-agent", enabled: false });
899
+ mockAgentStore.getAgent.mockReturnValue(agent);
900
+
901
+ const executeSpy = vi.spyOn(executor, "executeAgent");
902
+
903
+ executor.executeAgentManually("manual-agent", "some input");
904
+
905
+ // Need to advance timers to let the async execute complete
906
+ await vi.advanceTimersByTimeAsync(100);
907
+
908
+ expect(executeSpy).toHaveBeenCalledWith("manual-agent", "some input", {
909
+ force: true,
910
+ triggerType: "manual",
911
+ });
912
+ });
913
+ });
914
+
915
+ // =========================================================================
916
+ // getExecutions
917
+ // =========================================================================
918
+ describe("getExecutions", () => {
919
+ it("returns empty array for unknown agent", () => {
920
+ const result = executor.getExecutions("nonexistent-agent");
921
+ expect(result).toEqual([]);
922
+ });
923
+
924
+ it("returns executions after agent has run", async () => {
925
+ const agent = makeAgent({ id: "tracked-agent" });
926
+ mockAgentStore.getAgent.mockReturnValue(agent);
927
+
928
+ await executor.executeAgent("tracked-agent");
929
+
930
+ const executions = executor.getExecutions("tracked-agent");
931
+ expect(executions).toHaveLength(1);
932
+ expect(executions[0].agentId).toBe("tracked-agent");
933
+ expect(executions[0].sessionId).toBe("session-123");
934
+ });
935
+ });
936
+
937
+ // =========================================================================
938
+ // getNextRunTime
939
+ // =========================================================================
940
+ describe("getNextRunTime", () => {
941
+ it("returns null when no timer is set", () => {
942
+ expect(executor.getNextRunTime("no-timer-agent")).toBeNull();
943
+ });
944
+
945
+ it("returns the next run date from the Cron timer", () => {
946
+ const futureDate = new Date(Date.now() + 3600_000);
947
+
948
+ const agent = makeAgent({
949
+ id: "next-run-agent",
950
+ enabled: true,
951
+ triggers: { schedule: { enabled: true, expression: "0 * * * *", recurring: true } },
952
+ });
953
+
954
+ executor.scheduleAgent(agent);
955
+
956
+ // Configure the mock instance to return our future date
957
+ const cronInstance = getLastCronInstance();
958
+ cronInstance.nextRun.mockReturnValue(futureDate);
959
+
960
+ const nextRun = executor.getNextRunTime("next-run-agent");
961
+ expect(nextRun).toEqual(futureDate);
962
+ });
963
+
964
+ it("returns null when timer.nextRun() returns falsy", () => {
965
+ const agent = makeAgent({
966
+ id: "no-next-run-agent",
967
+ enabled: true,
968
+ triggers: { schedule: { enabled: true, expression: "0 * * * *", recurring: true } },
969
+ });
970
+
971
+ executor.scheduleAgent(agent);
972
+
973
+ // Configure the mock instance to return undefined (falsy)
974
+ const cronInstance = getLastCronInstance();
975
+ cronInstance.nextRun.mockReturnValue(undefined);
976
+
977
+ expect(executor.getNextRunTime("no-next-run-agent")).toBeNull();
978
+ });
979
+ });
980
+
981
+ // =========================================================================
982
+ // destroy
983
+ // =========================================================================
984
+ describe("destroy", () => {
985
+ it("stops all active timers and clears state", () => {
986
+ // Schedule two agents to create two Cron instances
987
+ const agent1 = makeAgent({
988
+ id: "destroy-agent-1",
989
+ enabled: true,
990
+ triggers: { schedule: { enabled: true, expression: "0 * * * *", recurring: true } },
991
+ });
992
+ const agent2 = makeAgent({
993
+ id: "destroy-agent-2",
994
+ enabled: true,
995
+ triggers: { schedule: { enabled: true, expression: "30 * * * *", recurring: true } },
996
+ });
997
+
998
+ executor.scheduleAgent(agent1);
999
+ executor.scheduleAgent(agent2);
1000
+
1001
+ expect(mockCronState.instances).toHaveLength(2);
1002
+ const instance1 = mockCronState.instances[0];
1003
+ const instance2 = mockCronState.instances[1];
1004
+
1005
+ executor.destroy();
1006
+
1007
+ // stop() should be called once on each timer instance
1008
+ expect(instance1.stop).toHaveBeenCalledOnce();
1009
+ expect(instance2.stop).toHaveBeenCalledOnce();
1010
+
1011
+ // After destroy, getNextRunTime should return null for both
1012
+ expect(executor.getNextRunTime("destroy-agent-1")).toBeNull();
1013
+ expect(executor.getNextRunTime("destroy-agent-2")).toBeNull();
1014
+
1015
+ // After destroy, getExecutions should return empty (executions map is cleared)
1016
+ expect(executor.getExecutions("destroy-agent-1")).toEqual([]);
1017
+ });
1018
+ });
1019
+
1020
+ // =========================================================================
1021
+ // permissionMode warning
1022
+ // =========================================================================
1023
+ describe("permissionMode warning", () => {
1024
+ it("logs warning when agent permissionMode differs from bypassPermissions", async () => {
1025
+ // An agent with permissionMode="plan" should trigger a console.warn
1026
+ // because agent sessions always run with bypassPermissions.
1027
+ const agent = makeAgent({
1028
+ id: "plan-mode-agent",
1029
+ name: "Plan Mode Agent",
1030
+ permissionMode: "plan",
1031
+ });
1032
+ mockAgentStore.getAgent.mockReturnValue(agent);
1033
+
1034
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1035
+
1036
+ await executor.executeAgent("plan-mode-agent");
1037
+
1038
+ // The warning should mention the agent's actual permissionMode
1039
+ expect(warnSpy).toHaveBeenCalledWith(
1040
+ expect.stringContaining('permissionMode="plan"'),
1041
+ );
1042
+ expect(warnSpy).toHaveBeenCalledWith(
1043
+ expect.stringContaining("bypassPermissions"),
1044
+ );
1045
+
1046
+ warnSpy.mockRestore();
1047
+ });
1048
+
1049
+ it("does not warn when permissionMode is bypassPermissions", async () => {
1050
+ const agent = makeAgent({
1051
+ id: "bypass-mode-agent",
1052
+ permissionMode: "bypassPermissions",
1053
+ });
1054
+ mockAgentStore.getAgent.mockReturnValue(agent);
1055
+
1056
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1057
+
1058
+ await executor.executeAgent("bypass-mode-agent");
1059
+
1060
+ // No warning about permissionMode should appear
1061
+ const permWarns = warnSpy.mock.calls.filter(
1062
+ (call) => typeof call[0] === "string" && call[0].includes("permissionMode"),
1063
+ );
1064
+ expect(permWarns).toHaveLength(0);
1065
+
1066
+ warnSpy.mockRestore();
1067
+ });
1068
+
1069
+ it("does not warn when permissionMode is not set (empty string)", async () => {
1070
+ // An empty string is falsy, so the guard `agent.permissionMode &&` fails
1071
+ const agent = makeAgent({
1072
+ id: "no-mode-agent",
1073
+ permissionMode: "",
1074
+ });
1075
+ mockAgentStore.getAgent.mockReturnValue(agent);
1076
+
1077
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1078
+
1079
+ await executor.executeAgent("no-mode-agent");
1080
+
1081
+ // No warning about permissionMode should appear
1082
+ const permWarns = warnSpy.mock.calls.filter(
1083
+ (call) => typeof call[0] === "string" && call[0].includes("permissionMode"),
1084
+ );
1085
+ expect(permWarns).toHaveLength(0);
1086
+
1087
+ warnSpy.mockRestore();
1088
+ });
1089
+ });
1090
+
1091
+ // =========================================================================
1092
+ // listAllExecutions (delegates to ExecutionStore)
1093
+ // =========================================================================
1094
+ describe("listAllExecutions", () => {
1095
+ it("delegates to executionStore.list()", () => {
1096
+ const mockResult = {
1097
+ executions: [{ sessionId: "s1", agentId: "a1", triggerType: "manual" as const, startedAt: 100 }],
1098
+ total: 1,
1099
+ };
1100
+ mockExecutionStoreInstance.list.mockReturnValue(mockResult);
1101
+
1102
+ const result = executor.listAllExecutions({ agentId: "a1", limit: 10 });
1103
+
1104
+ expect(mockExecutionStoreInstance.list).toHaveBeenCalledWith({ agentId: "a1", limit: 10 });
1105
+ expect(result).toEqual(mockResult);
1106
+ });
1107
+ });
1108
+ });