@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.
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-DCFhrJ28.js +13 -0
- package/dist/assets/CronManager-EGwLJONv.js +1 -0
- package/dist/assets/IntegrationsPage-CTMRnbQS.js +1 -0
- package/dist/assets/LinearOAuthSettingsPage-CgQFMIgr.js +1 -0
- package/dist/assets/LinearSettingsPage-C9nok1qi.js +1 -0
- package/dist/assets/Playground-BV3k0RbV.js +109 -0
- package/dist/assets/PromptsPage-CFojqNKP.js +4 -0
- package/dist/assets/RunsPage-DUJ1QUSa.js +1 -0
- package/dist/assets/SandboxManager-CrVQ-VU_.js +8 -0
- package/dist/assets/SettingsPage-D1fPCL19.js +1 -0
- package/dist/assets/TailscalePage-D06cyvyC.js +1 -0
- package/dist/assets/index-BhUa1e6X.css +1 -0
- package/dist/assets/index-DkqeP-R9.js +134 -0
- package/dist/assets/sw-register-BibwRdvC.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +20 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/sw.js +2 -0
- package/package.json +104 -0
- package/server/agent-cron-migrator.test.ts +610 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.test.ts +1108 -0
- package/server/agent-executor.ts +346 -0
- package/server/agent-store.test.ts +588 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-types.ts +138 -0
- package/server/ai-validation-settings.test.ts +128 -0
- package/server/ai-validation-settings.ts +35 -0
- package/server/ai-validator.test.ts +387 -0
- package/server/ai-validator.ts +271 -0
- package/server/auth-manager.test.ts +83 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-namer.test.ts +252 -0
- package/server/auto-namer.ts +78 -0
- package/server/backend-adapter.test.ts +38 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.test.ts +98 -0
- package/server/cache-headers.ts +61 -0
- package/server/claude-adapter.test.ts +1363 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.test.ts +44 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-protocol-contract.test.ts +71 -0
- package/server/claude-protocol-drift.test.ts +78 -0
- package/server/claude-session-discovery.test.ts +132 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.test.ts +158 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.test.ts +1343 -0
- package/server/cli-launcher.ts +1298 -0
- package/server/cli.test.ts +16 -0
- package/server/codex-adapter.test.ts +5545 -0
- package/server/codex-adapter.ts +3062 -0
- package/server/codex-container-auth.test.ts +50 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.test.ts +61 -0
- package/server/codex-home.ts +26 -0
- package/server/codex-protocol-contract.test.ts +96 -0
- package/server/codex-protocol-drift.test.ts +123 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.test.ts +179 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.test.ts +1211 -0
- package/server/container-manager.ts +1053 -0
- package/server/cron-scheduler.test.ts +957 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.test.ts +422 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/env-manager.test.ts +268 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +64 -0
- package/server/event-bus.test.ts +244 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.test.ts +307 -0
- package/server/execution-store.ts +170 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.test.ts +938 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.test.ts +498 -0
- package/server/github-pr.ts +379 -0
- package/server/image-pull-manager.test.ts +303 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +396 -0
- package/server/linear-agent-bridge.test.ts +1157 -0
- package/server/linear-agent-bridge.ts +629 -0
- package/server/linear-agent.test.ts +473 -0
- package/server/linear-agent.ts +479 -0
- package/server/linear-cache.test.ts +136 -0
- package/server/linear-cache.ts +113 -0
- package/server/linear-connections.test.ts +350 -0
- package/server/linear-connections.ts +231 -0
- package/server/linear-credential-migration.test.ts +337 -0
- package/server/linear-credential-migration.ts +63 -0
- package/server/linear-oauth-connections-migration.test.ts +268 -0
- package/server/linear-oauth-connections.test.ts +365 -0
- package/server/linear-oauth-connections.ts +294 -0
- package/server/linear-project-manager.test.ts +162 -0
- package/server/linear-project-manager.ts +111 -0
- package/server/linear-prompt-builder.test.ts +74 -0
- package/server/linear-prompt-builder.ts +61 -0
- package/server/linear-staging.test.ts +276 -0
- package/server/linear-staging.ts +142 -0
- package/server/logger.test.ts +393 -0
- package/server/logger.ts +259 -0
- package/server/metrics-collector.test.ts +413 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.test.ts +264 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.test.ts +333 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.test.ts +552 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.test.ts +31 -0
- package/server/paths.ts +11 -0
- package/server/pr-poller.test.ts +191 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.test.ts +211 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/recorder.test.ts +454 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.test.ts +150 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.test.ts +140 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.test.ts +44 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.test.ts +417 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.test.ts +262 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.test.ts +294 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.test.ts +337 -0
- package/server/relay-client.ts +320 -0
- package/server/replay.test.ts +200 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.test.ts +1400 -0
- package/server/routes/agent-routes.ts +409 -0
- package/server/routes/cron-routes.test.ts +881 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.test.ts +383 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/fs-routes.test.ts +1198 -0
- package/server/routes/fs-routes.ts +605 -0
- package/server/routes/git-routes.test.ts +813 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/linear-agent-routes.test.ts +721 -0
- package/server/routes/linear-agent-routes.ts +304 -0
- package/server/routes/linear-connection-routes.test.ts +927 -0
- package/server/routes/linear-connection-routes.ts +244 -0
- package/server/routes/linear-oauth-connection-routes.test.ts +406 -0
- package/server/routes/linear-oauth-connection-routes.ts +129 -0
- package/server/routes/linear-routes.test.ts +1510 -0
- package/server/routes/linear-routes.ts +953 -0
- package/server/routes/metrics-routes.test.ts +103 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/sandbox-routes.test.ts +513 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +270 -0
- package/server/routes/skills-routes.test.ts +690 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/system-routes.test.ts +637 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.test.ts +176 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes.test.ts +4655 -0
- package/server/routes.ts +1277 -0
- package/server/sandbox-manager.test.ts +378 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.test.ts +1419 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.test.ts +661 -0
- package/server/session-creation-service.ts +473 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-linear-issues.test.ts +118 -0
- package/server/session-linear-issues.ts +88 -0
- package/server/session-names.test.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.test.ts +1784 -0
- package/server/session-orchestrator.ts +973 -0
- package/server/session-state-machine.test.ts +606 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.test.ts +290 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +509 -0
- package/server/settings-manager.test.ts +275 -0
- package/server/settings-manager.ts +173 -0
- package/server/tailscale-manager.test.ts +553 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.test.ts +306 -0
- package/server/update-checker.ts +197 -0
- package/server/usage-limits.test.ts +536 -0
- package/server/usage-limits.ts +225 -0
- package/server/worktree-tracker.test.ts +243 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.test.ts +59 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.test.ts +272 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.test.ts +302 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.test.ts +1837 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.test.ts +124 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.test.ts +296 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.test.ts +234 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.test.ts +44 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +106 -0
- package/server/ws-bridge.test.ts +4777 -0
- 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
|
+
});
|