agent-mockingbird 0.0.1
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/.agents/skills/btca-cli/SKILL.md +64 -0
- package/.agents/skills/btca-cli/agents/openai.yaml +3 -0
- package/.agents/skills/frontend-design/SKILL.md +42 -0
- package/.agents/skills/frontend-design/agents/openai.yaml +3 -0
- package/.env.example +36 -0
- package/.githooks/pre-commit +33 -0
- package/.github/workflows/ci.yml +309 -0
- package/.opencode/bun.lock +18 -0
- package/.opencode/package.json +5 -0
- package/.opencode/tools/agent_type_manager.ts +100 -0
- package/.opencode/tools/config_manager.ts +87 -0
- package/.opencode/tools/cron_manager.ts +145 -0
- package/.opencode/tools/memory_get.ts +43 -0
- package/.opencode/tools/memory_remember.ts +53 -0
- package/.opencode/tools/memory_search.ts +48 -0
- package/AGENTS.md +126 -0
- package/MEMORY.md +2 -0
- package/README.md +451 -0
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/agent-mockingbird.config.example.json +135 -0
- package/apps/server/package.json +32 -0
- package/apps/server/src/backend/agents/bootstrapContext.ts +362 -0
- package/apps/server/src/backend/agents/openclawImport.test.ts +133 -0
- package/apps/server/src/backend/agents/openclawImport.ts +797 -0
- package/apps/server/src/backend/agents/opencodeConfig.ts +428 -0
- package/apps/server/src/backend/agents/service.ts +10 -0
- package/apps/server/src/backend/config/example-config.test.ts +20 -0
- package/apps/server/src/backend/config/orchestration.ts +243 -0
- package/apps/server/src/backend/config/policy.ts +158 -0
- package/apps/server/src/backend/config/schema.test.ts +15 -0
- package/apps/server/src/backend/config/schema.ts +391 -0
- package/apps/server/src/backend/config/semantic.test.ts +34 -0
- package/apps/server/src/backend/config/semantic.ts +149 -0
- package/apps/server/src/backend/config/service.test.ts +75 -0
- package/apps/server/src/backend/config/service.ts +207 -0
- package/apps/server/src/backend/config/smoke.ts +77 -0
- package/apps/server/src/backend/config/store.test.ts +123 -0
- package/apps/server/src/backend/config/store.ts +581 -0
- package/apps/server/src/backend/config/testFixtures.ts +5 -0
- package/apps/server/src/backend/config/types.ts +56 -0
- package/apps/server/src/backend/contracts/events.ts +320 -0
- package/apps/server/src/backend/contracts/runtime.ts +111 -0
- package/apps/server/src/backend/cron/executor.ts +435 -0
- package/apps/server/src/backend/cron/repository.ts +170 -0
- package/apps/server/src/backend/cron/service.ts +660 -0
- package/apps/server/src/backend/cron/storage.ts +92 -0
- package/apps/server/src/backend/cron/types.ts +138 -0
- package/apps/server/src/backend/cron/utils.ts +351 -0
- package/apps/server/src/backend/db/client.ts +20 -0
- package/apps/server/src/backend/db/migrate.ts +40 -0
- package/apps/server/src/backend/db/repository.ts +1762 -0
- package/apps/server/src/backend/db/schema.ts +113 -0
- package/apps/server/src/backend/db/usageDashboard.test.ts +102 -0
- package/apps/server/src/backend/db/wipe.ts +13 -0
- package/apps/server/src/backend/defaults.ts +32 -0
- package/apps/server/src/backend/env.ts +48 -0
- package/apps/server/src/backend/heartbeat/activeHours.ts +45 -0
- package/apps/server/src/backend/heartbeat/defaultJob.ts +88 -0
- package/apps/server/src/backend/heartbeat/heartbeat.test.ts +110 -0
- package/apps/server/src/backend/heartbeat/runtimeService.ts +190 -0
- package/apps/server/src/backend/heartbeat/service.ts +176 -0
- package/apps/server/src/backend/heartbeat/state.test.ts +63 -0
- package/apps/server/src/backend/heartbeat/state.ts +167 -0
- package/apps/server/src/backend/heartbeat/types.ts +54 -0
- package/apps/server/src/backend/http/boundedQueue.test.ts +49 -0
- package/apps/server/src/backend/http/boundedQueue.ts +92 -0
- package/apps/server/src/backend/http/parsers.ts +40 -0
- package/apps/server/src/backend/http/router.ts +61 -0
- package/apps/server/src/backend/http/routes/agentRoutes.ts +67 -0
- package/apps/server/src/backend/http/routes/backgroundRoutes.ts +203 -0
- package/apps/server/src/backend/http/routes/chatRoutes.ts +107 -0
- package/apps/server/src/backend/http/routes/configRoutes.ts +602 -0
- package/apps/server/src/backend/http/routes/cronRoutes.ts +221 -0
- package/apps/server/src/backend/http/routes/dashboardRoutes.ts +308 -0
- package/apps/server/src/backend/http/routes/eventRoutes.ts +7 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.test.ts +41 -0
- package/apps/server/src/backend/http/routes/heartbeatRoutes.ts +28 -0
- package/apps/server/src/backend/http/routes/index.ts +101 -0
- package/apps/server/src/backend/http/routes/mcpRoutes.ts +213 -0
- package/apps/server/src/backend/http/routes/memoryRoutes.ts +154 -0
- package/apps/server/src/backend/http/routes/runRoutes.ts +310 -0
- package/apps/server/src/backend/http/routes/runtimeRoutes.ts +197 -0
- package/apps/server/src/backend/http/routes/skillRoutes.ts +112 -0
- package/apps/server/src/backend/http/routes/uiRoutes.test.ts +161 -0
- package/apps/server/src/backend/http/routes/uiRoutes.ts +177 -0
- package/apps/server/src/backend/http/routes/usageRoutes.test.ts +104 -0
- package/apps/server/src/backend/http/routes/usageRoutes.ts +767 -0
- package/apps/server/src/backend/http/schemas.ts +64 -0
- package/apps/server/src/backend/http/sse.ts +144 -0
- package/apps/server/src/backend/integration/backend-core.test.ts +2316 -0
- package/apps/server/src/backend/logging/logger.ts +64 -0
- package/apps/server/src/backend/mcp/service.ts +326 -0
- package/apps/server/src/backend/memory/cli.ts +170 -0
- package/apps/server/src/backend/memory/conceptExpansion.test.ts +28 -0
- package/apps/server/src/backend/memory/conceptExpansion.ts +80 -0
- package/apps/server/src/backend/memory/qmdPort.test.ts +54 -0
- package/apps/server/src/backend/memory/qmdPort.ts +61 -0
- package/apps/server/src/backend/memory/records.test.ts +66 -0
- package/apps/server/src/backend/memory/records.ts +229 -0
- package/apps/server/src/backend/memory/service.ts +2012 -0
- package/apps/server/src/backend/memory/sqliteVec.ts +58 -0
- package/apps/server/src/backend/memory/types.ts +104 -0
- package/apps/server/src/backend/opencode/agentMockingbirdPlugin.test.ts +396 -0
- package/apps/server/src/backend/opencode/client.ts +98 -0
- package/apps/server/src/backend/opencode/models.ts +41 -0
- package/apps/server/src/backend/opencode/systemPrompt.test.ts +146 -0
- package/apps/server/src/backend/opencode/systemPrompt.ts +284 -0
- package/apps/server/src/backend/paths.ts +57 -0
- package/apps/server/src/backend/prompts/service.ts +100 -0
- package/apps/server/src/backend/queue/queue.test.ts +189 -0
- package/apps/server/src/backend/queue/service.ts +177 -0
- package/apps/server/src/backend/queue/types.ts +39 -0
- package/apps/server/src/backend/run/service.ts +576 -0
- package/apps/server/src/backend/run/storage.ts +47 -0
- package/apps/server/src/backend/run/types.ts +44 -0
- package/apps/server/src/backend/runtime/errors.ts +61 -0
- package/apps/server/src/backend/runtime/index.ts +72 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.test.ts +153 -0
- package/apps/server/src/backend/runtime/memoryPromptDedup.ts +76 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/backgroundMethods.ts +765 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/coreMethods.ts +705 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/eventMethods.ts +503 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/memoryMethods.ts +462 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/promptMethods.ts +1167 -0
- package/apps/server/src/backend/runtime/opencodeRuntime/shared.ts +254 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.test.ts +2899 -0
- package/apps/server/src/backend/runtime/opencodeRuntime.ts +135 -0
- package/apps/server/src/backend/runtime/sessionScope.ts +45 -0
- package/apps/server/src/backend/skills/service.ts +442 -0
- package/apps/server/src/backend/workspace/resolve.ts +27 -0
- package/apps/server/src/cli/agent-mockingbird.mjs +2522 -0
- package/apps/server/src/cli/agent-mockingbird.test.ts +68 -0
- package/apps/server/src/cli/runtime-assets.mjs +269 -0
- package/apps/server/src/cli/runtime-assets.test.ts +52 -0
- package/apps/server/src/cli/runtime-layout.mjs +75 -0
- package/apps/server/src/cli/standaloneBuild.test.ts +19 -0
- package/apps/server/src/cli/standaloneBuild.ts +19 -0
- package/apps/server/src/cli/standaloneCronBinary.test.ts +187 -0
- package/apps/server/src/index.ts +178 -0
- package/apps/server/tsconfig.json +12 -0
- package/backlog.md +5 -0
- package/bin/agent-mockingbird +2522 -0
- package/bin/runtime-layout.mjs +75 -0
- package/build-bin.ts +34 -0
- package/build-cli.mjs +37 -0
- package/build.ts +40 -0
- package/bun-env.d.ts +11 -0
- package/bun.lock +888 -0
- package/bunfig.toml +2 -0
- package/components.json +21 -0
- package/config.json +130 -0
- package/deploy/RELEASE_INSTALL.md +112 -0
- package/deploy/docker-compose.yml +42 -0
- package/deploy/systemd/README.md +46 -0
- package/deploy/systemd/agent-mockingbird.service +28 -0
- package/deploy/systemd/opencode.service +25 -0
- package/docs/legacy-config-ui-reference.md +51 -0
- package/docs/memory-e2e-trace-2026-03-04.md +63 -0
- package/docs/memory-ops.md +96 -0
- package/docs/memory-runtime-contract.md +42 -0
- package/docs/memory-tuning-remote-2026-03-04.md +59 -0
- package/docs/opencode-rebase-workflow-plan.md +614 -0
- package/docs/opencode-startup-sync-plan.md +94 -0
- package/docs/vendor-opencode.md +41 -0
- package/drizzle/0000_famous_turbo.sql +49 -0
- package/drizzle/0001_cron_memory_aux.sql +160 -0
- package/drizzle/0002_runtime_session_bindings.sql +28 -0
- package/drizzle/0003_background_runs.sql +27 -0
- package/drizzle/0004_memory_open_write.sql +63 -0
- package/drizzle/0005_signal_channel.sql +47 -0
- package/drizzle/0006_usage_event_dimensions.sql +7 -0
- package/drizzle/meta/0000_snapshot.json +341 -0
- package/drizzle/meta/_journal.json +55 -0
- package/drizzle.config.ts +14 -0
- package/eslint.config.mjs +77 -0
- package/knip.json +18 -0
- package/memory/2026-03-04.md +4 -0
- package/opencode.lock.json +16 -0
- package/package.json +67 -0
- package/packages/agent-mockingbird-installer/README.md +31 -0
- package/packages/agent-mockingbird-installer/bin/agent-mockingbird-installer.mjs +44 -0
- package/packages/agent-mockingbird-installer/opencode.lock.json +16 -0
- package/packages/agent-mockingbird-installer/package.json +23 -0
- package/packages/contracts/package.json +19 -0
- package/packages/contracts/src/agentTypes.ts +122 -0
- package/packages/contracts/src/cron.ts +146 -0
- package/packages/contracts/src/dashboard.ts +378 -0
- package/packages/contracts/src/index.ts +3 -0
- package/packages/contracts/tsconfig.json +4 -0
- package/patches/opencode/0001-Wafflebot-OpenCode-baseline.patch +2341 -0
- package/patches/opencode/0002-Fix-OpenCode-web-entry-and-settings-icons.patch +104 -0
- package/patches/opencode/0003-fix-app-remove-duplicate-sidebar-mount.patch +32 -0
- package/patches/opencode/0004-Add-heartbeat-settings-and-usage-nav.patch +506 -0
- package/patches/opencode/0005-Use-chart-icon-for-usage-nav.patch +38 -0
- package/patches/opencode/0006-Modernize-cron-settings.patch +399 -0
- package/patches/opencode/0007-Rename-waffle-namespaces-to-mockingbird.patch +1110 -0
- package/patches/opencode/0008-Remove-cron-contract-section.patch +178 -0
- package/patches/opencode/0009-Rework-cron-tab-as-operations-console.patch +414 -0
- package/patches/opencode/0010-Refine-heartbeat-settings-controls.patch +208 -0
- package/runtime-assets/opencode-config/opencode.jsonc +25 -0
- package/runtime-assets/opencode-config/package.json +5 -0
- package/runtime-assets/opencode-config/plugins/agent-mockingbird.ts +715 -0
- package/runtime-assets/workspace/.agents/skills/config-auditor/SKILL.md +25 -0
- package/runtime-assets/workspace/.agents/skills/config-editor/SKILL.md +24 -0
- package/runtime-assets/workspace/.agents/skills/cron-manager/SKILL.md +57 -0
- package/runtime-assets/workspace/.agents/skills/memory-ops/SKILL.md +120 -0
- package/runtime-assets/workspace/.agents/skills/runtime-diagnose/SKILL.md +25 -0
- package/runtime-assets/workspace/AGENTS.md +56 -0
- package/runtime-assets/workspace/MEMORY.md +4 -0
- package/scripts/build-release-bundle.sh +66 -0
- package/scripts/check-ship.ts +383 -0
- package/scripts/dev-opencode.sh +17 -0
- package/scripts/dev-stack-opencode.sh +15 -0
- package/scripts/dev-stack.sh +61 -0
- package/scripts/install-systemd.sh +87 -0
- package/scripts/memory-e2e.sh +76 -0
- package/scripts/memory-trace-e2e.sh +141 -0
- package/scripts/migrate-opencode-env.ts +108 -0
- package/scripts/onboard/bootstrap.sh +32 -0
- package/scripts/opencode-swap.ts +78 -0
- package/scripts/opencode-sync.ts +715 -0
- package/scripts/runtime-assets-sync.mjs +83 -0
- package/scripts/setup-git-hooks.ts +39 -0
- package/tsconfig.json +45 -0
- package/tui.json +98 -0
- package/turbo.json +36 -0
- package/vendor/OPENCODE_VENDOR.md +13 -0
|
@@ -0,0 +1,2316 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const testRoot = mkdtempSync(path.join(tmpdir(), "agent-mockingbird-backend-test-"));
|
|
7
|
+
const testDbPath = path.join(testRoot, "agent-mockingbird.test.db");
|
|
8
|
+
const testWorkspacePath = path.join(testRoot, "workspace");
|
|
9
|
+
const testConfigPath = path.join(testRoot, "agent-mockingbird.test.config.json");
|
|
10
|
+
|
|
11
|
+
process.env.NODE_ENV = "test";
|
|
12
|
+
process.env.AGENT_MOCKINGBIRD_DB_PATH = testDbPath;
|
|
13
|
+
process.env.AGENT_MOCKINGBIRD_CONFIG_PATH = testConfigPath;
|
|
14
|
+
process.env.AGENT_MOCKINGBIRD_MEMORY_WORKSPACE_DIR = testWorkspacePath;
|
|
15
|
+
process.env.AGENT_MOCKINGBIRD_MEMORY_EMBED_PROVIDER = "none";
|
|
16
|
+
process.env.AGENT_MOCKINGBIRD_MEMORY_ENABLED = "true";
|
|
17
|
+
process.env.AGENT_MOCKINGBIRD_CRON_ENABLED = "true";
|
|
18
|
+
|
|
19
|
+
interface SessionSummaryLite {
|
|
20
|
+
id: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UsageSnapshotLite {
|
|
24
|
+
requestCount: number;
|
|
25
|
+
inputTokens: number;
|
|
26
|
+
outputTokens: number;
|
|
27
|
+
estimatedCostUsd: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface HeartbeatSnapshotLite {
|
|
31
|
+
online: boolean;
|
|
32
|
+
at: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface RepositoryApi {
|
|
36
|
+
ensureSeedData: () => void;
|
|
37
|
+
resetDatabaseToDefaults: () => unknown;
|
|
38
|
+
createSession: (input?: { title?: string; model?: string }) => SessionSummaryLite;
|
|
39
|
+
getSessionById: (sessionId: string) => { id: string; model: string } | null;
|
|
40
|
+
setSessionModel: (sessionId: string, model: string) => { id: string; model: string } | null;
|
|
41
|
+
getUsageSnapshot: () => UsageSnapshotLite;
|
|
42
|
+
getHeartbeatSnapshot: () => HeartbeatSnapshotLite;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface MemoryWriteEventLite {
|
|
46
|
+
status: "accepted" | "rejected";
|
|
47
|
+
sessionId: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface MemoryRememberResultLite {
|
|
51
|
+
accepted: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface MemoryServiceApi {
|
|
55
|
+
initializeMemory: () => Promise<void>;
|
|
56
|
+
rememberMemory: (input: {
|
|
57
|
+
source: "user" | "assistant" | "system";
|
|
58
|
+
content: string;
|
|
59
|
+
sessionId?: string;
|
|
60
|
+
confidence?: number;
|
|
61
|
+
}) => Promise<MemoryRememberResultLite>;
|
|
62
|
+
listMemoryWriteEvents: (limit?: number) => Promise<MemoryWriteEventLite[]>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface RuntimeStub {
|
|
66
|
+
sendUserMessage: (input: {
|
|
67
|
+
sessionId: string;
|
|
68
|
+
content: string;
|
|
69
|
+
agent?: string;
|
|
70
|
+
metadata?: Record<string, unknown>;
|
|
71
|
+
}) => Promise<unknown>;
|
|
72
|
+
subscribe: (_listener: (event: unknown) => void) => () => void;
|
|
73
|
+
syncSessionMessages?: (sessionId: string) => Promise<void>;
|
|
74
|
+
checkHealth?: (_input?: { force?: boolean }) => Promise<{
|
|
75
|
+
ok: boolean;
|
|
76
|
+
error: { name: string; message: string } | null;
|
|
77
|
+
fromCache: boolean;
|
|
78
|
+
}>;
|
|
79
|
+
abortSession?: (sessionId: string) => Promise<boolean>;
|
|
80
|
+
compactSession?: (sessionId: string) => Promise<boolean>;
|
|
81
|
+
spawnBackgroundSession?: (input: {
|
|
82
|
+
parentSessionId: string;
|
|
83
|
+
title?: string;
|
|
84
|
+
requestedBy?: string;
|
|
85
|
+
prompt?: string;
|
|
86
|
+
}) => Promise<{
|
|
87
|
+
runId: string;
|
|
88
|
+
parentSessionId: string;
|
|
89
|
+
parentExternalSessionId: string;
|
|
90
|
+
childExternalSessionId: string;
|
|
91
|
+
childSessionId: string | null;
|
|
92
|
+
status: string;
|
|
93
|
+
startedAt: string | null;
|
|
94
|
+
completedAt: string | null;
|
|
95
|
+
error: string | null;
|
|
96
|
+
}>;
|
|
97
|
+
promptBackgroundAsync?: (input: {
|
|
98
|
+
runId: string;
|
|
99
|
+
content: string;
|
|
100
|
+
model?: string;
|
|
101
|
+
system?: string;
|
|
102
|
+
agent?: string;
|
|
103
|
+
noReply?: boolean;
|
|
104
|
+
}) => Promise<{
|
|
105
|
+
runId: string;
|
|
106
|
+
parentSessionId: string;
|
|
107
|
+
parentExternalSessionId: string;
|
|
108
|
+
childExternalSessionId: string;
|
|
109
|
+
childSessionId: string | null;
|
|
110
|
+
status: string;
|
|
111
|
+
startedAt: string | null;
|
|
112
|
+
completedAt: string | null;
|
|
113
|
+
error: string | null;
|
|
114
|
+
}>;
|
|
115
|
+
getBackgroundStatus?: (runId: string) => Promise<{
|
|
116
|
+
runId: string;
|
|
117
|
+
parentSessionId: string;
|
|
118
|
+
parentExternalSessionId: string;
|
|
119
|
+
childExternalSessionId: string;
|
|
120
|
+
childSessionId: string | null;
|
|
121
|
+
status: string;
|
|
122
|
+
startedAt: string | null;
|
|
123
|
+
completedAt: string | null;
|
|
124
|
+
error: string | null;
|
|
125
|
+
} | null>;
|
|
126
|
+
listBackgroundRuns?: (input?: {
|
|
127
|
+
parentSessionId?: string;
|
|
128
|
+
limit?: number;
|
|
129
|
+
inFlightOnly?: boolean;
|
|
130
|
+
}) => Promise<
|
|
131
|
+
Array<{
|
|
132
|
+
runId: string;
|
|
133
|
+
parentSessionId: string;
|
|
134
|
+
parentExternalSessionId: string;
|
|
135
|
+
childExternalSessionId: string;
|
|
136
|
+
childSessionId: string | null;
|
|
137
|
+
status: string;
|
|
138
|
+
startedAt: string | null;
|
|
139
|
+
completedAt: string | null;
|
|
140
|
+
error: string | null;
|
|
141
|
+
}>
|
|
142
|
+
>;
|
|
143
|
+
abortBackground?: (runId: string) => Promise<boolean>;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface CronJobDefinitionLite {
|
|
147
|
+
id: string;
|
|
148
|
+
name?: string;
|
|
149
|
+
everyMs?: number | null;
|
|
150
|
+
threadSessionId?: string | null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface CronJobInstanceLite {
|
|
154
|
+
id: string;
|
|
155
|
+
agentInvoked?: boolean;
|
|
156
|
+
state: "queued" | "leased" | "running" | "completed" | "failed" | "dead";
|
|
157
|
+
attempt: number;
|
|
158
|
+
error?: unknown;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface CronServiceInstance {
|
|
162
|
+
createJob: (input: {
|
|
163
|
+
id?: string;
|
|
164
|
+
name: string;
|
|
165
|
+
enabled?: boolean;
|
|
166
|
+
scheduleKind: "at" | "every" | "cron";
|
|
167
|
+
scheduleExpr?: string | null;
|
|
168
|
+
everyMs?: number | null;
|
|
169
|
+
atIso?: string | null;
|
|
170
|
+
timezone?: string | null;
|
|
171
|
+
runMode: "background" | "agent" | "conditional_agent";
|
|
172
|
+
conditionModulePath?: string | null;
|
|
173
|
+
agentPromptTemplate?: string | null;
|
|
174
|
+
maxAttempts?: number;
|
|
175
|
+
retryBackoffMs?: number;
|
|
176
|
+
payload?: Record<string, unknown>;
|
|
177
|
+
}) => Promise<CronJobDefinitionLite>;
|
|
178
|
+
upsertJob: (input: {
|
|
179
|
+
id: string;
|
|
180
|
+
name: string;
|
|
181
|
+
enabled?: boolean;
|
|
182
|
+
scheduleKind: "at" | "every" | "cron";
|
|
183
|
+
scheduleExpr?: string | null;
|
|
184
|
+
everyMs?: number | null;
|
|
185
|
+
atIso?: string | null;
|
|
186
|
+
timezone?: string | null;
|
|
187
|
+
runMode: "background" | "agent" | "conditional_agent";
|
|
188
|
+
conditionModulePath?: string | null;
|
|
189
|
+
agentPromptTemplate?: string | null;
|
|
190
|
+
maxAttempts?: number;
|
|
191
|
+
retryBackoffMs?: number;
|
|
192
|
+
payload?: Record<string, unknown>;
|
|
193
|
+
}) => Promise<{ created: boolean; job: CronJobDefinitionLite }>;
|
|
194
|
+
updateJob: (jobId: string, patch: { enabled?: boolean }) => Promise<CronJobDefinitionLite & { enabled: boolean }>;
|
|
195
|
+
getJob: (jobId: string) => Promise<(CronJobDefinitionLite & { enabled: boolean }) | null>;
|
|
196
|
+
runJobNow: (jobId: string) => Promise<{ queued: boolean; instanceId: string | null }>;
|
|
197
|
+
listJobs: () => Promise<CronJobDefinitionLite[]>;
|
|
198
|
+
listInstances: (input?: { jobId?: string; limit?: number }) => Promise<CronJobInstanceLite[]>;
|
|
199
|
+
listSteps: (instanceId: string) => Promise<Array<{ stepKind: string; status: string }>>;
|
|
200
|
+
notifyMainThread: (input: {
|
|
201
|
+
runtimeSessionId: string;
|
|
202
|
+
prompt: string;
|
|
203
|
+
severity?: "info" | "warn" | "critical";
|
|
204
|
+
}) => Promise<{ delivered: true; threadSessionId: string; sourceKind: "cron" | "heartbeat"; cronJobId?: string }>;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type CronServiceCtor = new (runtime: RuntimeStub) => CronServiceInstance;
|
|
208
|
+
type RuntimeSessionNotFoundErrorCtor = new (sessionId: string) => Error;
|
|
209
|
+
type RuntimeSessionQueuedErrorCtor = new (sessionId: string, depth: number) => Error;
|
|
210
|
+
type RuntimeContinuationDetachedErrorCtor = new (sessionId: string, childRunCount: number) => Error;
|
|
211
|
+
|
|
212
|
+
type RouteHandler = (req: Request) => Response | Promise<Response>;
|
|
213
|
+
type RouteMethods = {
|
|
214
|
+
GET?: RouteHandler;
|
|
215
|
+
POST?: RouteHandler;
|
|
216
|
+
PUT?: RouteHandler;
|
|
217
|
+
PATCH?: RouteHandler;
|
|
218
|
+
DELETE?: RouteHandler;
|
|
219
|
+
};
|
|
220
|
+
type RouteTable = Record<string, RouteHandler | RouteMethods>;
|
|
221
|
+
|
|
222
|
+
interface RuntimeEventStreamApi {
|
|
223
|
+
publish: (_event: unknown) => void;
|
|
224
|
+
route: {
|
|
225
|
+
GET: () => Response;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface AgentRunLite {
|
|
230
|
+
id: string;
|
|
231
|
+
state: "queued" | "running" | "completed" | "failed";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
interface RunServiceInstance {
|
|
235
|
+
start: () => void;
|
|
236
|
+
stop: () => void;
|
|
237
|
+
createRun: (input: {
|
|
238
|
+
sessionId: string;
|
|
239
|
+
content: string;
|
|
240
|
+
metadata?: Record<string, unknown>;
|
|
241
|
+
idempotencyKey?: string;
|
|
242
|
+
}) => { run: AgentRunLite; deduplicated: boolean };
|
|
243
|
+
getRunById: (runId: string) => AgentRunLite | null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
type RunServiceCtor = new (runtime: RuntimeStub) => RunServiceInstance;
|
|
247
|
+
|
|
248
|
+
type CreateApiRoutesFn = (input: {
|
|
249
|
+
runtime: RuntimeStub;
|
|
250
|
+
cronService: CronServiceInstance;
|
|
251
|
+
eventStream: RuntimeEventStreamApi;
|
|
252
|
+
runService: RunServiceInstance;
|
|
253
|
+
}) => RouteTable;
|
|
254
|
+
|
|
255
|
+
type CreateRuntimeEventStreamFn = (input: {
|
|
256
|
+
getHeartbeatSnapshot: () => HeartbeatSnapshotLite;
|
|
257
|
+
getUsageSnapshot: () => UsageSnapshotLite;
|
|
258
|
+
}) => RuntimeEventStreamApi;
|
|
259
|
+
|
|
260
|
+
type RuntimeEventLite = {
|
|
261
|
+
id: string;
|
|
262
|
+
type: string;
|
|
263
|
+
source: string;
|
|
264
|
+
at: string;
|
|
265
|
+
payload: unknown;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
type StreamChunk = Uint8Array | string;
|
|
269
|
+
|
|
270
|
+
interface SqliteDb {
|
|
271
|
+
query: (sql: string) => {
|
|
272
|
+
run: (...bindings: Array<string | number | null>) => unknown;
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let repository: RepositoryApi;
|
|
277
|
+
let memoryService: MemoryServiceApi;
|
|
278
|
+
let createApiRoutes: CreateApiRoutesFn;
|
|
279
|
+
let createRuntimeEventStream: CreateRuntimeEventStreamFn;
|
|
280
|
+
let CronService: CronServiceCtor;
|
|
281
|
+
let RunService: RunServiceCtor;
|
|
282
|
+
let RuntimeSessionNotFoundError: RuntimeSessionNotFoundErrorCtor;
|
|
283
|
+
let RuntimeSessionQueuedError: RuntimeSessionQueuedErrorCtor;
|
|
284
|
+
let RuntimeContinuationDetachedError: RuntimeContinuationDetachedErrorCtor;
|
|
285
|
+
let sqlite: SqliteDb;
|
|
286
|
+
|
|
287
|
+
beforeAll(async () => {
|
|
288
|
+
await import("../db/migrate");
|
|
289
|
+
repository = (await import("../db/repository")) as unknown as RepositoryApi;
|
|
290
|
+
memoryService = (await import("../memory/service")) as unknown as MemoryServiceApi;
|
|
291
|
+
({ createApiRoutes } = (await import("../http/routes")) as unknown as {
|
|
292
|
+
createApiRoutes: CreateApiRoutesFn;
|
|
293
|
+
});
|
|
294
|
+
({ createRuntimeEventStream } = (await import("../http/sse")) as unknown as {
|
|
295
|
+
createRuntimeEventStream: CreateRuntimeEventStreamFn;
|
|
296
|
+
});
|
|
297
|
+
({ CronService } = (await import("../cron/service")) as unknown as {
|
|
298
|
+
CronService: CronServiceCtor;
|
|
299
|
+
});
|
|
300
|
+
({ RunService } = (await import("../run/service")) as unknown as {
|
|
301
|
+
RunService: RunServiceCtor;
|
|
302
|
+
});
|
|
303
|
+
({
|
|
304
|
+
RuntimeSessionNotFoundError,
|
|
305
|
+
RuntimeSessionQueuedError,
|
|
306
|
+
RuntimeContinuationDetachedError,
|
|
307
|
+
} = (await import("../runtime/errors")) as unknown as {
|
|
308
|
+
RuntimeSessionNotFoundError: RuntimeSessionNotFoundErrorCtor;
|
|
309
|
+
RuntimeSessionQueuedError: RuntimeSessionQueuedErrorCtor;
|
|
310
|
+
RuntimeContinuationDetachedError: RuntimeContinuationDetachedErrorCtor;
|
|
311
|
+
});
|
|
312
|
+
({ sqlite } = (await import("../db/client")) as unknown as {
|
|
313
|
+
sqlite: SqliteDb;
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
repository.ensureSeedData();
|
|
317
|
+
await memoryService.initializeMemory();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
beforeEach(async () => {
|
|
321
|
+
repository.resetDatabaseToDefaults();
|
|
322
|
+
rmSync(testWorkspacePath, { recursive: true, force: true });
|
|
323
|
+
await memoryService.initializeMemory();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
afterAll(() => {
|
|
327
|
+
rmSync(testRoot, { recursive: true, force: true });
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
function createRuntimeStub(
|
|
331
|
+
impl: (input: {
|
|
332
|
+
sessionId: string;
|
|
333
|
+
content: string;
|
|
334
|
+
agent?: string;
|
|
335
|
+
metadata?: Record<string, unknown>;
|
|
336
|
+
}) => Promise<unknown>,
|
|
337
|
+
options?: {
|
|
338
|
+
checkHealth?: RuntimeStub["checkHealth"];
|
|
339
|
+
},
|
|
340
|
+
) {
|
|
341
|
+
return {
|
|
342
|
+
sendUserMessage: impl,
|
|
343
|
+
subscribe: () => () => {},
|
|
344
|
+
checkHealth: options?.checkHealth,
|
|
345
|
+
abortSession: async () => true,
|
|
346
|
+
compactSession: async () => true,
|
|
347
|
+
spawnBackgroundSession: async (input: {
|
|
348
|
+
parentSessionId: string;
|
|
349
|
+
title?: string;
|
|
350
|
+
requestedBy?: string;
|
|
351
|
+
prompt?: string;
|
|
352
|
+
}) => {
|
|
353
|
+
const child = repository.createSession({ title: input.title ?? "Background Session" });
|
|
354
|
+
sqlite
|
|
355
|
+
.query(
|
|
356
|
+
`
|
|
357
|
+
INSERT INTO runtime_session_bindings (runtime, session_id, external_session_id, updated_at)
|
|
358
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
359
|
+
ON CONFLICT(runtime, session_id) DO UPDATE SET
|
|
360
|
+
external_session_id = excluded.external_session_id,
|
|
361
|
+
updated_at = excluded.updated_at
|
|
362
|
+
`,
|
|
363
|
+
)
|
|
364
|
+
.run("opencode", child.id, `ext-${child.id}`, Date.now());
|
|
365
|
+
return {
|
|
366
|
+
runId: `bg-${child.id}`,
|
|
367
|
+
parentSessionId: input.parentSessionId,
|
|
368
|
+
parentExternalSessionId: "ext-main",
|
|
369
|
+
childExternalSessionId: `ext-${child.id}`,
|
|
370
|
+
childSessionId: child.id,
|
|
371
|
+
status: "created",
|
|
372
|
+
startedAt: null,
|
|
373
|
+
completedAt: null,
|
|
374
|
+
error: null,
|
|
375
|
+
};
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function createRouteHarness(
|
|
381
|
+
runtimeImpl: (input: {
|
|
382
|
+
sessionId: string;
|
|
383
|
+
content: string;
|
|
384
|
+
agent?: string;
|
|
385
|
+
metadata?: Record<string, unknown>;
|
|
386
|
+
}) => Promise<unknown>,
|
|
387
|
+
options?: {
|
|
388
|
+
checkHealth?: RuntimeStub["checkHealth"];
|
|
389
|
+
},
|
|
390
|
+
) {
|
|
391
|
+
const runtime = createRuntimeStub(runtimeImpl, options);
|
|
392
|
+
const cronService = new CronService(runtime);
|
|
393
|
+
const runService = new RunService(runtime);
|
|
394
|
+
runService.start();
|
|
395
|
+
const eventStream = createRuntimeEventStream({
|
|
396
|
+
getHeartbeatSnapshot: repository.getHeartbeatSnapshot,
|
|
397
|
+
getUsageSnapshot: repository.getUsageSnapshot,
|
|
398
|
+
});
|
|
399
|
+
const routes = createApiRoutes({
|
|
400
|
+
runtime,
|
|
401
|
+
cronService,
|
|
402
|
+
eventStream,
|
|
403
|
+
runService,
|
|
404
|
+
});
|
|
405
|
+
return { routes, cronService, eventStream, runService };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function readWithTimeout(
|
|
409
|
+
reader: ReadableStreamDefaultReader<StreamChunk>,
|
|
410
|
+
timeoutMs: number,
|
|
411
|
+
): Promise<Awaited<ReturnType<ReadableStreamDefaultReader<StreamChunk>["read"]>>> {
|
|
412
|
+
return await Promise.race([
|
|
413
|
+
reader.read(),
|
|
414
|
+
new Promise<never>((_resolve, reject) => {
|
|
415
|
+
setTimeout(() => reject(new Error("stream read timed out")), timeoutMs);
|
|
416
|
+
}),
|
|
417
|
+
]);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function sleep(ms: number) {
|
|
421
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function drainInitialSseFrames(reader: ReadableStreamDefaultReader<StreamChunk>) {
|
|
425
|
+
const decoder = new TextDecoder();
|
|
426
|
+
let combined = "";
|
|
427
|
+
for (let i = 0; i < 3; i += 1) {
|
|
428
|
+
const chunk = await readWithTimeout(reader, 250);
|
|
429
|
+
if (chunk.done || !chunk.value) break;
|
|
430
|
+
combined += typeof chunk.value === "string" ? chunk.value : decoder.decode(chunk.value);
|
|
431
|
+
if (combined.includes("event: heartbeat") && combined.includes("event: usage")) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async function publishAndReadSseFrame(event: RuntimeEventLite) {
|
|
438
|
+
const eventStream = createRuntimeEventStream({
|
|
439
|
+
getHeartbeatSnapshot: repository.getHeartbeatSnapshot,
|
|
440
|
+
getUsageSnapshot: repository.getUsageSnapshot,
|
|
441
|
+
});
|
|
442
|
+
const response = eventStream.route.GET();
|
|
443
|
+
const reader = response.body?.getReader() as ReadableStreamDefaultReader<StreamChunk> | undefined;
|
|
444
|
+
expect(reader).toBeTruthy();
|
|
445
|
+
if (!reader) {
|
|
446
|
+
throw new Error("Missing SSE reader");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
await drainInitialSseFrames(reader);
|
|
450
|
+
eventStream.publish(event);
|
|
451
|
+
|
|
452
|
+
const decoder = new TextDecoder();
|
|
453
|
+
let combined = "";
|
|
454
|
+
for (let i = 0; i < 3; i += 1) {
|
|
455
|
+
const chunk = await readWithTimeout(reader, 250);
|
|
456
|
+
if (chunk.done || !chunk.value) break;
|
|
457
|
+
combined += typeof chunk.value === "string" ? chunk.value : decoder.decode(chunk.value);
|
|
458
|
+
if (combined.includes(`data: ${JSON.stringify(event.payload)}`)) {
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
await reader.cancel();
|
|
464
|
+
return combined;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
describe("chat routes", () => {
|
|
468
|
+
test("POST /api/chat returns messages for known session", async () => {
|
|
469
|
+
const session = repository.createSession({ title: "Route Test Session" });
|
|
470
|
+
const now = new Date().toISOString();
|
|
471
|
+
const { routes } = createRouteHarness(async input => ({
|
|
472
|
+
sessionId: input.sessionId,
|
|
473
|
+
messages: [
|
|
474
|
+
{ id: "user-1", role: "user", content: input.content, at: now },
|
|
475
|
+
{ id: "assistant-1", role: "assistant", content: "ack", at: now },
|
|
476
|
+
],
|
|
477
|
+
}));
|
|
478
|
+
|
|
479
|
+
const route = routes["/api/chat"] as { POST: (req: Request) => Promise<Response> };
|
|
480
|
+
const response = await route.POST(
|
|
481
|
+
new Request("http://localhost/api/chat", {
|
|
482
|
+
method: "POST",
|
|
483
|
+
headers: { "Content-Type": "application/json" },
|
|
484
|
+
body: JSON.stringify({ sessionId: session.id, content: "hello runtime" }),
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
expect(response.status).toBe(200);
|
|
489
|
+
const payload = (await response.json()) as { messages: Array<{ id: string }>; session: { id: string } };
|
|
490
|
+
expect(payload.session.id).toBe(session.id);
|
|
491
|
+
expect(payload.messages.length).toBe(2);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("POST /api/chat returns 404 for unknown session from runtime", async () => {
|
|
495
|
+
const { routes } = createRouteHarness(async input => {
|
|
496
|
+
throw new RuntimeSessionNotFoundError(input.sessionId);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const route = routes["/api/chat"] as { POST: (req: Request) => Promise<Response> };
|
|
500
|
+
const response = await route.POST(
|
|
501
|
+
new Request("http://localhost/api/chat", {
|
|
502
|
+
method: "POST",
|
|
503
|
+
headers: { "Content-Type": "application/json" },
|
|
504
|
+
body: JSON.stringify({ sessionId: "missing-session", content: "hello runtime" }),
|
|
505
|
+
}),
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
expect(response.status).toBe(404);
|
|
509
|
+
const payload = (await response.json()) as { error: string };
|
|
510
|
+
expect(payload.error).toBe("Unknown session");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("POST /api/chat fails fast when runtime preflight is unhealthy", async () => {
|
|
514
|
+
const session = repository.createSession({ title: "Preflight Failure Session" });
|
|
515
|
+
let sendCalled = false;
|
|
516
|
+
const { routes } = createRouteHarness(
|
|
517
|
+
async () => {
|
|
518
|
+
sendCalled = true;
|
|
519
|
+
return { sessionId: session.id, messages: [] };
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
checkHealth: async () => ({
|
|
523
|
+
ok: false,
|
|
524
|
+
fromCache: false,
|
|
525
|
+
error: {
|
|
526
|
+
name: "RuntimeProviderAuthError",
|
|
527
|
+
message: "Provider authentication failed. Check API key or provider credentials.",
|
|
528
|
+
},
|
|
529
|
+
}),
|
|
530
|
+
},
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
const route = routes["/api/chat"] as { POST: (req: Request) => Promise<Response> };
|
|
534
|
+
const response = await route.POST(
|
|
535
|
+
new Request("http://localhost/api/chat", {
|
|
536
|
+
method: "POST",
|
|
537
|
+
headers: { "Content-Type": "application/json" },
|
|
538
|
+
body: JSON.stringify({ sessionId: session.id, content: "hello runtime" }),
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
expect(response.status).toBe(502);
|
|
543
|
+
const payload = (await response.json()) as { error: string };
|
|
544
|
+
expect(payload.error).toContain("Runtime preflight failed");
|
|
545
|
+
expect(sendCalled).toBe(false);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("POST /api/chat/:id/abort returns aborted=true", async () => {
|
|
549
|
+
const session = repository.createSession({ title: "Abort Session" });
|
|
550
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: session.id, messages: [] }));
|
|
551
|
+
|
|
552
|
+
const route = routes["/api/chat/:id/abort"] as {
|
|
553
|
+
POST: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
554
|
+
};
|
|
555
|
+
const response = await route.POST(
|
|
556
|
+
Object.assign(new Request(`http://localhost/api/chat/${session.id}/abort`, { method: "POST" }), {
|
|
557
|
+
params: { id: session.id },
|
|
558
|
+
}),
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
expect(response.status).toBe(200);
|
|
562
|
+
const payload = (await response.json()) as { aborted: boolean };
|
|
563
|
+
expect(payload.aborted).toBe(true);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("POST /api/chat/:id/compact returns compacted=true", async () => {
|
|
567
|
+
const session = repository.createSession({ title: "Compact Session" });
|
|
568
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: session.id, messages: [] }));
|
|
569
|
+
|
|
570
|
+
const route = routes["/api/chat/:id/compact"] as {
|
|
571
|
+
POST: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
572
|
+
};
|
|
573
|
+
const response = await route.POST(
|
|
574
|
+
Object.assign(new Request(`http://localhost/api/chat/${session.id}/compact`, { method: "POST" }), {
|
|
575
|
+
params: { id: session.id },
|
|
576
|
+
}),
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
expect(response.status).toBe(200);
|
|
580
|
+
const payload = (await response.json()) as { compacted: boolean };
|
|
581
|
+
expect(payload.compacted).toBe(true);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test("PUT /api/sessions/:id/model updates session model even when runtime-default patch fails", async () => {
|
|
585
|
+
const session = repository.createSession({ title: "Model Route Session", model: "opencode/old-model" });
|
|
586
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: session.id, messages: [] }));
|
|
587
|
+
|
|
588
|
+
const route = routes["/api/sessions/:id/model"] as {
|
|
589
|
+
PUT: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
590
|
+
};
|
|
591
|
+
const response = await route.PUT(
|
|
592
|
+
Object.assign(new Request(`http://localhost/api/sessions/${session.id}/model`, {
|
|
593
|
+
method: "PUT",
|
|
594
|
+
headers: { "Content-Type": "application/json" },
|
|
595
|
+
body: JSON.stringify({ model: "opencode/new-model" }),
|
|
596
|
+
}), {
|
|
597
|
+
params: { id: session.id },
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
expect(response.status).toBe(200);
|
|
602
|
+
const payload = (await response.json()) as {
|
|
603
|
+
session?: { id: string; model: string };
|
|
604
|
+
configError?: string;
|
|
605
|
+
};
|
|
606
|
+
expect(payload.session?.id).toBe(session.id);
|
|
607
|
+
expect(payload.session?.model).toBe("opencode/new-model");
|
|
608
|
+
const persisted = repository.getSessionById(session.id);
|
|
609
|
+
expect(persisted?.model).toBe("opencode/new-model");
|
|
610
|
+
if (typeof payload.configError === "string") {
|
|
611
|
+
expect(payload.configError.length).toBeGreaterThan(0);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("PUT /api/sessions/:id/model returns 404 for unknown session", async () => {
|
|
616
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
617
|
+
const route = routes["/api/sessions/:id/model"] as {
|
|
618
|
+
PUT: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
619
|
+
};
|
|
620
|
+
const response = await route.PUT(
|
|
621
|
+
Object.assign(new Request("http://localhost/api/sessions/missing/model", {
|
|
622
|
+
method: "PUT",
|
|
623
|
+
headers: { "Content-Type": "application/json" },
|
|
624
|
+
body: JSON.stringify({ model: "opencode/new-model" }),
|
|
625
|
+
}), {
|
|
626
|
+
params: { id: "missing" },
|
|
627
|
+
}),
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
expect(response.status).toBe(404);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("PUT /api/runtime/default-model updates runtime default model", async () => {
|
|
634
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
635
|
+
const route = routes["/api/runtime/default-model"] as {
|
|
636
|
+
PUT: (req: Request) => Promise<Response>;
|
|
637
|
+
};
|
|
638
|
+
const response = await route.PUT(
|
|
639
|
+
new Request("http://localhost/api/runtime/default-model", {
|
|
640
|
+
method: "PUT",
|
|
641
|
+
headers: { "Content-Type": "application/json" },
|
|
642
|
+
body: JSON.stringify({ model: "opencode/runtime-default-updated" }),
|
|
643
|
+
}),
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
expect([200, 422]).toContain(response.status);
|
|
647
|
+
const payload = (await response.json()) as {
|
|
648
|
+
runtimeDefaultModel?: string;
|
|
649
|
+
configHash?: string;
|
|
650
|
+
error?: string;
|
|
651
|
+
};
|
|
652
|
+
if (response.status === 200) {
|
|
653
|
+
expect(payload.runtimeDefaultModel).toBe("opencode/runtime-default-updated");
|
|
654
|
+
expect(typeof payload.configHash).toBe("string");
|
|
655
|
+
|
|
656
|
+
const { getConfigSnapshot } = (await import("../config/service")) as unknown as {
|
|
657
|
+
getConfigSnapshot: () => { config: { runtime: { opencode: { providerId: string; modelId: string } } } };
|
|
658
|
+
};
|
|
659
|
+
const snapshot = getConfigSnapshot();
|
|
660
|
+
expect(snapshot.config.runtime.opencode.providerId).toBe("opencode");
|
|
661
|
+
expect(snapshot.config.runtime.opencode.modelId).toBe("runtime-default-updated");
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
expect(typeof payload.error).toBe("string");
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
describe("runtime health route", () => {
|
|
670
|
+
test("GET /api/runtime/health returns probe snapshot", async () => {
|
|
671
|
+
let receivedForce = false;
|
|
672
|
+
const { routes } = createRouteHarness(
|
|
673
|
+
async () => ({ sessionId: "main", messages: [] }),
|
|
674
|
+
{
|
|
675
|
+
checkHealth: async (input) => {
|
|
676
|
+
receivedForce = input?.force === true;
|
|
677
|
+
return {
|
|
678
|
+
ok: true,
|
|
679
|
+
fromCache: false,
|
|
680
|
+
error: null,
|
|
681
|
+
};
|
|
682
|
+
},
|
|
683
|
+
},
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
const route = routes["/api/runtime/health"] as { GET: (req: Request) => Promise<Response> };
|
|
687
|
+
const response = await route.GET(new Request("http://localhost/api/runtime/health?force=1"));
|
|
688
|
+
|
|
689
|
+
expect(response.status).toBe(200);
|
|
690
|
+
const payload = (await response.json()) as { health: { ok: boolean } };
|
|
691
|
+
expect(payload.health.ok).toBe(true);
|
|
692
|
+
expect(receivedForce).toBe(true);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("GET /api/runtime/health returns 503 for unhealthy runtime", async () => {
|
|
696
|
+
const { routes } = createRouteHarness(
|
|
697
|
+
async () => ({ sessionId: "main", messages: [] }),
|
|
698
|
+
{
|
|
699
|
+
checkHealth: async () => ({
|
|
700
|
+
ok: false,
|
|
701
|
+
fromCache: false,
|
|
702
|
+
error: {
|
|
703
|
+
name: "RuntimeProviderQuotaError",
|
|
704
|
+
message: "Provider quota exceeded. Add credits or switch provider/model.",
|
|
705
|
+
},
|
|
706
|
+
}),
|
|
707
|
+
},
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
const route = routes["/api/runtime/health"] as { GET: (req: Request) => Promise<Response> };
|
|
711
|
+
const response = await route.GET(new Request("http://localhost/api/runtime/health"));
|
|
712
|
+
|
|
713
|
+
expect(response.status).toBe(503);
|
|
714
|
+
const payload = (await response.json()) as { health: { ok: boolean } };
|
|
715
|
+
expect(payload.health.ok).toBe(false);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test("GET /api/runtime/info returns opencode metadata", async () => {
|
|
719
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
720
|
+
const route = routes["/api/runtime/info"] as { GET: (req: Request) => Promise<Response> };
|
|
721
|
+
const response = await route.GET(new Request("http://localhost/api/runtime/info"));
|
|
722
|
+
|
|
723
|
+
expect(response.status).toBe(200);
|
|
724
|
+
const payload = (await response.json()) as {
|
|
725
|
+
configAuthority?: { source?: string; path?: string; hash?: string };
|
|
726
|
+
opencode?: {
|
|
727
|
+
baseUrl?: string;
|
|
728
|
+
workspaceDirectory?: string;
|
|
729
|
+
configDirectory?: string;
|
|
730
|
+
effectiveConfigPath?: string;
|
|
731
|
+
};
|
|
732
|
+
};
|
|
733
|
+
expect(payload.configAuthority?.source).toBe("agent-mockingbird-config-json");
|
|
734
|
+
expect(typeof payload.configAuthority?.path).toBe("string");
|
|
735
|
+
expect(typeof payload.configAuthority?.hash).toBe("string");
|
|
736
|
+
expect(typeof payload.opencode?.baseUrl).toBe("string");
|
|
737
|
+
expect(typeof payload.opencode?.workspaceDirectory).toBe("string");
|
|
738
|
+
expect(typeof payload.opencode?.configDirectory).toBe("string");
|
|
739
|
+
expect(typeof payload.opencode?.effectiveConfigPath).toBe("string");
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
describe("config routes", () => {
|
|
744
|
+
test("GET /api/opencode/agents is exposed", async () => {
|
|
745
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
746
|
+
const route = routes["/api/opencode/agents"] as { GET: (req: Request) => Promise<Response> };
|
|
747
|
+
const response = await route.GET(new Request("http://localhost/api/opencode/agents"));
|
|
748
|
+
expect([200, 502]).toContain(response.status);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("POST /api/opencode/agents/validate reports invalid upserts", async () => {
|
|
752
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
753
|
+
const route = routes["/api/opencode/agents/validate"] as {
|
|
754
|
+
POST: (req: Request) => Promise<Response>;
|
|
755
|
+
};
|
|
756
|
+
const response = await route.POST(
|
|
757
|
+
new Request("http://localhost/api/opencode/agents/validate", {
|
|
758
|
+
method: "POST",
|
|
759
|
+
headers: { "Content-Type": "application/json" },
|
|
760
|
+
body: JSON.stringify({
|
|
761
|
+
upserts: [
|
|
762
|
+
{
|
|
763
|
+
id: "",
|
|
764
|
+
name: "Broken",
|
|
765
|
+
},
|
|
766
|
+
],
|
|
767
|
+
deletes: [],
|
|
768
|
+
}),
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
expect(response.status).toBe(200);
|
|
773
|
+
const payload = (await response.json()) as {
|
|
774
|
+
ok?: boolean;
|
|
775
|
+
issues?: Array<{ message?: string }>;
|
|
776
|
+
};
|
|
777
|
+
expect(payload.ok).toBe(false);
|
|
778
|
+
expect((payload.issues ?? []).length).toBeGreaterThan(0);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("PATCH /api/opencode/agents requires expectedHash", async () => {
|
|
782
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
783
|
+
const route = routes["/api/opencode/agents"] as {
|
|
784
|
+
PATCH: (req: Request) => Promise<Response>;
|
|
785
|
+
};
|
|
786
|
+
const response = await route.PATCH(
|
|
787
|
+
new Request("http://localhost/api/opencode/agents", {
|
|
788
|
+
method: "PATCH",
|
|
789
|
+
headers: { "Content-Type": "application/json" },
|
|
790
|
+
body: JSON.stringify({
|
|
791
|
+
upserts: [],
|
|
792
|
+
deletes: [],
|
|
793
|
+
}),
|
|
794
|
+
}),
|
|
795
|
+
);
|
|
796
|
+
|
|
797
|
+
expect(response.status).toBe(400);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
test("POST /api/config/patch-safe requires expectedHash", async () => {
|
|
801
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
802
|
+
const route = routes["/api/config/patch-safe"] as {
|
|
803
|
+
POST: (req: Request) => Promise<Response>;
|
|
804
|
+
};
|
|
805
|
+
const response = await route.POST(
|
|
806
|
+
new Request("http://localhost/api/config/patch-safe", {
|
|
807
|
+
method: "POST",
|
|
808
|
+
headers: { "Content-Type": "application/json" },
|
|
809
|
+
body: JSON.stringify({
|
|
810
|
+
patch: {
|
|
811
|
+
runtime: {
|
|
812
|
+
runStream: {
|
|
813
|
+
heartbeatMs: 16000,
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
}),
|
|
818
|
+
}),
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
expect(response.status).toBe(400);
|
|
822
|
+
const payload = (await response.json()) as {
|
|
823
|
+
stage?: string;
|
|
824
|
+
error?: string;
|
|
825
|
+
};
|
|
826
|
+
expect(payload.stage).toBe("request");
|
|
827
|
+
expect(typeof payload.error).toBe("string");
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
test("POST /api/config/patch-safe rejects denylisted paths", async () => {
|
|
831
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
832
|
+
const configRoute = routes["/api/config"] as { GET: (req: Request) => Response };
|
|
833
|
+
const configResponse = configRoute.GET(new Request("http://localhost/api/config"));
|
|
834
|
+
const snapshot = (await configResponse.json()) as { hash: string };
|
|
835
|
+
|
|
836
|
+
const route = routes["/api/config/patch-safe"] as {
|
|
837
|
+
POST: (req: Request) => Promise<Response>;
|
|
838
|
+
};
|
|
839
|
+
const response = await route.POST(
|
|
840
|
+
new Request("http://localhost/api/config/patch-safe", {
|
|
841
|
+
method: "POST",
|
|
842
|
+
headers: { "Content-Type": "application/json" },
|
|
843
|
+
body: JSON.stringify({
|
|
844
|
+
expectedHash: snapshot.hash,
|
|
845
|
+
runSmokeTest: false,
|
|
846
|
+
patch: {
|
|
847
|
+
runtime: {
|
|
848
|
+
smokeTest: {
|
|
849
|
+
prompt: "override prompt",
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
}),
|
|
854
|
+
}),
|
|
855
|
+
);
|
|
856
|
+
|
|
857
|
+
expect(response.status).toBe(422);
|
|
858
|
+
const payload = (await response.json()) as {
|
|
859
|
+
stage?: string;
|
|
860
|
+
details?: { rejectedPaths?: string[] };
|
|
861
|
+
};
|
|
862
|
+
expect(payload.stage).toBe("policy");
|
|
863
|
+
expect(Array.isArray(payload.details?.rejectedPaths)).toBe(true);
|
|
864
|
+
expect(payload.details?.rejectedPaths?.some(path => path.startsWith("runtime.smokeTest"))).toBe(true);
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
test("POST /api/config/opencode/bootstrap/import-openclaw migrates workspace content in one step", async () => {
|
|
868
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
869
|
+
|
|
870
|
+
const sourceDir = path.join(testRoot, "openclaw-source");
|
|
871
|
+
const targetDir = path.join(testRoot, "openclaw-target");
|
|
872
|
+
rmSync(sourceDir, { recursive: true, force: true });
|
|
873
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
874
|
+
mkdirSync(path.join(sourceDir, "memory"), { recursive: true });
|
|
875
|
+
mkdirSync(targetDir, { recursive: true });
|
|
876
|
+
writeFileSync(path.join(sourceDir, "AGENTS.md"), "# Agents\n- imported\n", "utf8");
|
|
877
|
+
writeFileSync(path.join(sourceDir, "memory", "notes.md"), "# Memory\nhello\n", "utf8");
|
|
878
|
+
writeFileSync(path.join(sourceDir, "README.txt"), "not markdown", "utf8");
|
|
879
|
+
writeFileSync(path.join(targetDir, "AGENTS.md"), "# Agents\n- existing\n", "utf8");
|
|
880
|
+
|
|
881
|
+
const importRoute = routes["/api/config/opencode/bootstrap/import-openclaw"] as {
|
|
882
|
+
POST: (req: Request) => Promise<Response>;
|
|
883
|
+
};
|
|
884
|
+
const importResponse = await importRoute.POST(
|
|
885
|
+
new Request("http://localhost/api/config/opencode/bootstrap/import-openclaw", {
|
|
886
|
+
method: "POST",
|
|
887
|
+
headers: { "Content-Type": "application/json" },
|
|
888
|
+
body: JSON.stringify({
|
|
889
|
+
source: {
|
|
890
|
+
mode: "local",
|
|
891
|
+
path: sourceDir,
|
|
892
|
+
},
|
|
893
|
+
targetDirectory: targetDir,
|
|
894
|
+
}),
|
|
895
|
+
}),
|
|
896
|
+
);
|
|
897
|
+
expect(importResponse.status).toBe(200);
|
|
898
|
+
const importPayload = (await importResponse.json()) as {
|
|
899
|
+
migration?: {
|
|
900
|
+
copied?: Array<{ relativePath?: string }>;
|
|
901
|
+
merged?: Array<{ relativePath?: string }>;
|
|
902
|
+
skippedExisting?: Array<{ relativePath?: string }>;
|
|
903
|
+
};
|
|
904
|
+
memorySync?: { attempted?: boolean; completed?: boolean; error?: string | null };
|
|
905
|
+
};
|
|
906
|
+
expect(importPayload.migration?.copied?.some(file => file.relativePath === "memory/notes.md")).toBe(true);
|
|
907
|
+
expect(importPayload.migration?.merged?.some(file => file.relativePath === "AGENTS.md")).toBe(false);
|
|
908
|
+
expect(importPayload.migration?.skippedExisting?.some(file => file.relativePath === "AGENTS.md")).toBe(true);
|
|
909
|
+
expect(readFileSync(path.join(targetDir, "AGENTS.md"), "utf8")).toContain("existing");
|
|
910
|
+
expect(readFileSync(path.join(targetDir, "memory", "notes.md"), "utf8")).toContain("hello");
|
|
911
|
+
expect(importPayload.memorySync?.attempted).toBe(true);
|
|
912
|
+
expect(importPayload.memorySync?.completed).toBe(true);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
test("POST /api/config/opencode/bootstrap/import-openclaw defaults target to workspace", async () => {
|
|
916
|
+
const { routes } = createRouteHarness(async () => ({ sessionId: "main", messages: [] }));
|
|
917
|
+
|
|
918
|
+
const sourceDir = path.join(testRoot, "openclaw-source-default-target");
|
|
919
|
+
rmSync(sourceDir, { recursive: true, force: true });
|
|
920
|
+
mkdirSync(sourceDir, { recursive: true });
|
|
921
|
+
writeFileSync(path.join(sourceDir, "IMPORT_TEST.md"), "# Imported default target\n", "utf8");
|
|
922
|
+
|
|
923
|
+
const importRoute = routes["/api/config/opencode/bootstrap/import-openclaw"] as {
|
|
924
|
+
POST: (req: Request) => Promise<Response>;
|
|
925
|
+
};
|
|
926
|
+
const importResponse = await importRoute.POST(
|
|
927
|
+
new Request("http://localhost/api/config/opencode/bootstrap/import-openclaw", {
|
|
928
|
+
method: "POST",
|
|
929
|
+
headers: { "Content-Type": "application/json" },
|
|
930
|
+
body: JSON.stringify({
|
|
931
|
+
source: {
|
|
932
|
+
mode: "local",
|
|
933
|
+
path: sourceDir,
|
|
934
|
+
},
|
|
935
|
+
}),
|
|
936
|
+
}),
|
|
937
|
+
);
|
|
938
|
+
expect(importResponse.status).toBe(200);
|
|
939
|
+
const importPayload = (await importResponse.json()) as {
|
|
940
|
+
migration?: {
|
|
941
|
+
targetDirectory?: string;
|
|
942
|
+
};
|
|
943
|
+
};
|
|
944
|
+
const targetDirectory = importPayload.migration?.targetDirectory;
|
|
945
|
+
expect(typeof targetDirectory).toBe("string");
|
|
946
|
+
expect(readFileSync(path.join(targetDirectory as string, "IMPORT_TEST.md"), "utf8")).toContain("Imported default target");
|
|
947
|
+
});
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
describe("run routes", () => {
|
|
951
|
+
test("POST /api/runs accepts and completes asynchronously", async () => {
|
|
952
|
+
const session = repository.createSession({ title: "Run Session" });
|
|
953
|
+
const now = new Date().toISOString();
|
|
954
|
+
const { routes } = createRouteHarness(async input => ({
|
|
955
|
+
sessionId: input.sessionId,
|
|
956
|
+
messages: [
|
|
957
|
+
{ id: "run-user-1", role: "user", content: input.content, at: now },
|
|
958
|
+
{ id: "run-assistant-1", role: "assistant", content: "run ack", at: now },
|
|
959
|
+
],
|
|
960
|
+
}));
|
|
961
|
+
|
|
962
|
+
const createRoute = routes["/api/runs"] as { POST: (req: Request) => Promise<Response> };
|
|
963
|
+
const createResponse = await createRoute.POST(
|
|
964
|
+
new Request("http://localhost/api/runs", {
|
|
965
|
+
method: "POST",
|
|
966
|
+
headers: { "Content-Type": "application/json" },
|
|
967
|
+
body: JSON.stringify({ sessionId: session.id, content: "ship it" }),
|
|
968
|
+
}),
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
expect(createResponse.status).toBe(202);
|
|
972
|
+
const createPayload = (await createResponse.json()) as {
|
|
973
|
+
accepted: boolean;
|
|
974
|
+
runId: string;
|
|
975
|
+
run: { id: string; state: string };
|
|
976
|
+
};
|
|
977
|
+
expect(createPayload.accepted).toBe(true);
|
|
978
|
+
expect(createPayload.runId).toBeTruthy();
|
|
979
|
+
expect(createPayload.run.id).toBe(createPayload.runId);
|
|
980
|
+
|
|
981
|
+
const runRoute = routes["/api/runs/:id"] as {
|
|
982
|
+
GET: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
983
|
+
};
|
|
984
|
+
|
|
985
|
+
let latestRunState = createPayload.run.state;
|
|
986
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
987
|
+
const runResponse = await runRoute.GET(
|
|
988
|
+
Object.assign(new Request(`http://localhost/api/runs/${createPayload.runId}`), {
|
|
989
|
+
params: { id: createPayload.runId },
|
|
990
|
+
}),
|
|
991
|
+
);
|
|
992
|
+
expect(runResponse.status).toBe(200);
|
|
993
|
+
const runPayload = (await runResponse.json()) as {
|
|
994
|
+
run: { state: string; result?: { messageCount?: number; messageIds?: string[] } };
|
|
995
|
+
};
|
|
996
|
+
latestRunState = runPayload.run.state;
|
|
997
|
+
if (latestRunState === "completed") {
|
|
998
|
+
expect(runPayload.run.result?.messageCount).toBe(2);
|
|
999
|
+
expect(runPayload.run.result?.messageIds?.length).toBe(2);
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
await sleep(20);
|
|
1003
|
+
}
|
|
1004
|
+
expect(latestRunState).toBe("completed");
|
|
1005
|
+
|
|
1006
|
+
const eventsRoute = routes["/api/runs/:id/events"] as {
|
|
1007
|
+
GET: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
1008
|
+
};
|
|
1009
|
+
const eventsResponse = await eventsRoute.GET(
|
|
1010
|
+
Object.assign(new Request(`http://localhost/api/runs/${createPayload.runId}/events?afterSeq=0&limit=20`), {
|
|
1011
|
+
params: { id: createPayload.runId },
|
|
1012
|
+
}),
|
|
1013
|
+
);
|
|
1014
|
+
expect(eventsResponse.status).toBe(200);
|
|
1015
|
+
const eventsPayload = (await eventsResponse.json()) as {
|
|
1016
|
+
events: Array<{ seq: number; type: string }>;
|
|
1017
|
+
hasMore: boolean;
|
|
1018
|
+
nextAfterSeq: number;
|
|
1019
|
+
};
|
|
1020
|
+
const eventTypes = eventsPayload.events.map(event => event.type);
|
|
1021
|
+
expect(eventTypes).toContain("run.accepted");
|
|
1022
|
+
expect(eventTypes).toContain("run.started");
|
|
1023
|
+
expect(eventTypes).toContain("run.completed");
|
|
1024
|
+
expect(eventsPayload.hasMore).toBe(false);
|
|
1025
|
+
expect(eventsPayload.nextAfterSeq).toBeGreaterThan(0);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
test("POST /api/runs forwards optional agent to runtime", async () => {
|
|
1029
|
+
const session = repository.createSession({ title: "Agent Run Session" });
|
|
1030
|
+
let seenAgent: string | undefined;
|
|
1031
|
+
const { routes } = createRouteHarness(async input => {
|
|
1032
|
+
seenAgent = input.agent;
|
|
1033
|
+
return { sessionId: input.sessionId, messages: [] };
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const createRoute = routes["/api/runs"] as { POST: (req: Request) => Promise<Response> };
|
|
1037
|
+
const createResponse = await createRoute.POST(
|
|
1038
|
+
new Request("http://localhost/api/runs", {
|
|
1039
|
+
method: "POST",
|
|
1040
|
+
headers: { "Content-Type": "application/json" },
|
|
1041
|
+
body: JSON.stringify({
|
|
1042
|
+
sessionId: session.id,
|
|
1043
|
+
content: "run with agent",
|
|
1044
|
+
agent: "planner",
|
|
1045
|
+
}),
|
|
1046
|
+
}),
|
|
1047
|
+
);
|
|
1048
|
+
expect(createResponse.status).toBe(202);
|
|
1049
|
+
|
|
1050
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
1051
|
+
expect(seenAgent).toBe("planner");
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
test("POST /api/runs deduplicates by idempotencyKey", async () => {
|
|
1055
|
+
const session = repository.createSession({ title: "Run Dedupe Session" });
|
|
1056
|
+
const { routes } = createRouteHarness(async input => ({
|
|
1057
|
+
sessionId: input.sessionId,
|
|
1058
|
+
messages: [
|
|
1059
|
+
{ id: "run-user-2", role: "user", content: input.content, at: new Date().toISOString() },
|
|
1060
|
+
{ id: "run-assistant-2", role: "assistant", content: "done", at: new Date().toISOString() },
|
|
1061
|
+
],
|
|
1062
|
+
}));
|
|
1063
|
+
const route = routes["/api/runs"] as { POST: (req: Request) => Promise<Response> };
|
|
1064
|
+
|
|
1065
|
+
const first = await route.POST(
|
|
1066
|
+
new Request("http://localhost/api/runs", {
|
|
1067
|
+
method: "POST",
|
|
1068
|
+
headers: { "Content-Type": "application/json" },
|
|
1069
|
+
body: JSON.stringify({
|
|
1070
|
+
sessionId: session.id,
|
|
1071
|
+
content: "dedupe me",
|
|
1072
|
+
idempotencyKey: "idem-run-1",
|
|
1073
|
+
}),
|
|
1074
|
+
}),
|
|
1075
|
+
);
|
|
1076
|
+
expect(first.status).toBe(202);
|
|
1077
|
+
const firstPayload = (await first.json()) as { runId: string; deduplicated: boolean };
|
|
1078
|
+
expect(firstPayload.deduplicated).toBe(false);
|
|
1079
|
+
|
|
1080
|
+
const second = await route.POST(
|
|
1081
|
+
new Request("http://localhost/api/runs", {
|
|
1082
|
+
method: "POST",
|
|
1083
|
+
headers: { "Content-Type": "application/json" },
|
|
1084
|
+
body: JSON.stringify({
|
|
1085
|
+
sessionId: session.id,
|
|
1086
|
+
content: "dedupe me",
|
|
1087
|
+
idempotencyKey: "idem-run-1",
|
|
1088
|
+
}),
|
|
1089
|
+
}),
|
|
1090
|
+
);
|
|
1091
|
+
expect(second.status).toBe(200);
|
|
1092
|
+
const secondPayload = (await second.json()) as { runId: string; deduplicated: boolean };
|
|
1093
|
+
expect(secondPayload.deduplicated).toBe(true);
|
|
1094
|
+
expect(secondPayload.runId).toBe(firstPayload.runId);
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
test("POST /api/runs treats queued runtime result as run.completed (not failure)", async () => {
|
|
1098
|
+
const session = repository.createSession({ title: "Queued Run Session" });
|
|
1099
|
+
const { routes } = createRouteHarness(async input => {
|
|
1100
|
+
throw new RuntimeSessionQueuedError(input.sessionId, 2);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
const createRoute = routes["/api/runs"] as { POST: (req: Request) => Promise<Response> };
|
|
1104
|
+
const createResponse = await createRoute.POST(
|
|
1105
|
+
new Request("http://localhost/api/runs", {
|
|
1106
|
+
method: "POST",
|
|
1107
|
+
headers: { "Content-Type": "application/json" },
|
|
1108
|
+
body: JSON.stringify({ sessionId: session.id, content: "queue me" }),
|
|
1109
|
+
}),
|
|
1110
|
+
);
|
|
1111
|
+
|
|
1112
|
+
expect(createResponse.status).toBe(202);
|
|
1113
|
+
const createPayload = (await createResponse.json()) as { runId: string };
|
|
1114
|
+
const runRoute = routes["/api/runs/:id"] as {
|
|
1115
|
+
GET: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
let latestState = "queued";
|
|
1119
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
1120
|
+
const response = await runRoute.GET(
|
|
1121
|
+
Object.assign(new Request(`http://localhost/api/runs/${createPayload.runId}`), {
|
|
1122
|
+
params: { id: createPayload.runId },
|
|
1123
|
+
}),
|
|
1124
|
+
);
|
|
1125
|
+
const payload = (await response.json()) as {
|
|
1126
|
+
run: { state: string; result?: { queued?: boolean; queueDepth?: number } };
|
|
1127
|
+
};
|
|
1128
|
+
latestState = payload.run.state;
|
|
1129
|
+
if (latestState === "completed") {
|
|
1130
|
+
expect(payload.run.result?.queued).toBe(true);
|
|
1131
|
+
expect(payload.run.result?.queueDepth).toBe(2);
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
await sleep(20);
|
|
1135
|
+
}
|
|
1136
|
+
expect(latestState).toBe("completed");
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
test("POST /api/runs treats detached runtime continuation as run.completed (not failure)", async () => {
|
|
1140
|
+
const session = repository.createSession({ title: "Detached Run Session" });
|
|
1141
|
+
const { routes } = createRouteHarness(async input => {
|
|
1142
|
+
throw new RuntimeContinuationDetachedError(input.sessionId, 3);
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
const createRoute = routes["/api/runs"] as { POST: (req: Request) => Promise<Response> };
|
|
1146
|
+
const createResponse = await createRoute.POST(
|
|
1147
|
+
new Request("http://localhost/api/runs", {
|
|
1148
|
+
method: "POST",
|
|
1149
|
+
headers: { "Content-Type": "application/json" },
|
|
1150
|
+
body: JSON.stringify({ sessionId: session.id, content: "detach me" }),
|
|
1151
|
+
}),
|
|
1152
|
+
);
|
|
1153
|
+
|
|
1154
|
+
expect(createResponse.status).toBe(202);
|
|
1155
|
+
const createPayload = (await createResponse.json()) as { runId: string };
|
|
1156
|
+
const runRoute = routes["/api/runs/:id"] as {
|
|
1157
|
+
GET: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
let latestState = "queued";
|
|
1161
|
+
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
1162
|
+
const response = await runRoute.GET(
|
|
1163
|
+
Object.assign(new Request(`http://localhost/api/runs/${createPayload.runId}`), {
|
|
1164
|
+
params: { id: createPayload.runId },
|
|
1165
|
+
}),
|
|
1166
|
+
);
|
|
1167
|
+
const payload = (await response.json()) as {
|
|
1168
|
+
run: { state: string; result?: { detached?: boolean; childRunCount?: number } };
|
|
1169
|
+
};
|
|
1170
|
+
latestState = payload.run.state;
|
|
1171
|
+
if (latestState === "completed") {
|
|
1172
|
+
expect(payload.run.result?.detached).toBe(true);
|
|
1173
|
+
expect(payload.run.result?.childRunCount).toBe(3);
|
|
1174
|
+
break;
|
|
1175
|
+
}
|
|
1176
|
+
await sleep(20);
|
|
1177
|
+
}
|
|
1178
|
+
expect(latestState).toBe("completed");
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
test("GET /api/runs/:id/events/stream replays and streams run events", async () => {
|
|
1182
|
+
const session = repository.createSession({ title: "Run Stream Session" });
|
|
1183
|
+
const { routes } = createRouteHarness(async input => ({
|
|
1184
|
+
sessionId: input.sessionId,
|
|
1185
|
+
messages: [
|
|
1186
|
+
{ id: "run-user-stream", role: "user", content: input.content, at: new Date().toISOString() },
|
|
1187
|
+
{ id: "run-assistant-stream", role: "assistant", content: "stream ack", at: new Date().toISOString() },
|
|
1188
|
+
],
|
|
1189
|
+
}));
|
|
1190
|
+
|
|
1191
|
+
const createRoute = routes["/api/runs"] as { POST: (req: Request) => Promise<Response> };
|
|
1192
|
+
const createResponse = await createRoute.POST(
|
|
1193
|
+
new Request("http://localhost/api/runs", {
|
|
1194
|
+
method: "POST",
|
|
1195
|
+
headers: { "Content-Type": "application/json" },
|
|
1196
|
+
body: JSON.stringify({ sessionId: session.id, content: "stream it" }),
|
|
1197
|
+
}),
|
|
1198
|
+
);
|
|
1199
|
+
expect(createResponse.status).toBe(202);
|
|
1200
|
+
const createPayload = (await createResponse.json()) as { runId: string };
|
|
1201
|
+
|
|
1202
|
+
const streamRoute = routes["/api/runs/:id/events/stream"] as {
|
|
1203
|
+
GET: (req: Request & { params: { id: string } }) => Response;
|
|
1204
|
+
};
|
|
1205
|
+
const streamResponse = await streamRoute.GET(
|
|
1206
|
+
Object.assign(new Request(`http://localhost/api/runs/${createPayload.runId}/events/stream?afterSeq=0`), {
|
|
1207
|
+
params: { id: createPayload.runId },
|
|
1208
|
+
}),
|
|
1209
|
+
);
|
|
1210
|
+
|
|
1211
|
+
expect(streamResponse.status).toBe(200);
|
|
1212
|
+
expect(streamResponse.headers.get("content-type")).toContain("text/event-stream");
|
|
1213
|
+
expect(streamResponse.body).toBeTruthy();
|
|
1214
|
+
|
|
1215
|
+
const reader = streamResponse.body!.getReader();
|
|
1216
|
+
const decoder = new TextDecoder();
|
|
1217
|
+
let frames = "";
|
|
1218
|
+
|
|
1219
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
1220
|
+
const chunk = await readWithTimeout(reader, 1_000);
|
|
1221
|
+
if (chunk.done) break;
|
|
1222
|
+
const text = typeof chunk.value === "string" ? chunk.value : decoder.decode(chunk.value, { stream: true });
|
|
1223
|
+
frames += text;
|
|
1224
|
+
if (frames.includes("\"type\":\"run.completed\"")) {
|
|
1225
|
+
break;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
expect(frames).toContain("event: run-event");
|
|
1230
|
+
expect(frames).toContain("\"type\":\"run.accepted\"");
|
|
1231
|
+
expect(frames).toContain("\"type\":\"run.completed\"");
|
|
1232
|
+
|
|
1233
|
+
await reader.cancel();
|
|
1234
|
+
});
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
describe("background routes", () => {
|
|
1238
|
+
test("spawn, list, steer, and abort background runs", async () => {
|
|
1239
|
+
const session = repository.createSession({ title: "Background API Session" });
|
|
1240
|
+
|
|
1241
|
+
const runs = new Map<
|
|
1242
|
+
string,
|
|
1243
|
+
{
|
|
1244
|
+
runId: string;
|
|
1245
|
+
parentSessionId: string;
|
|
1246
|
+
parentExternalSessionId: string;
|
|
1247
|
+
childExternalSessionId: string;
|
|
1248
|
+
childSessionId: string | null;
|
|
1249
|
+
status: string;
|
|
1250
|
+
startedAt: string | null;
|
|
1251
|
+
completedAt: string | null;
|
|
1252
|
+
error: string | null;
|
|
1253
|
+
}
|
|
1254
|
+
>();
|
|
1255
|
+
const runtime: RuntimeStub = {
|
|
1256
|
+
...createRuntimeStub(async () => ({ sessionId: session.id, messages: [] })),
|
|
1257
|
+
spawnBackgroundSession: async (input: {
|
|
1258
|
+
parentSessionId: string;
|
|
1259
|
+
title?: string;
|
|
1260
|
+
requestedBy?: string;
|
|
1261
|
+
prompt?: string;
|
|
1262
|
+
}) => {
|
|
1263
|
+
const runId = "bg-route-1";
|
|
1264
|
+
const run = {
|
|
1265
|
+
runId,
|
|
1266
|
+
parentSessionId: input.parentSessionId,
|
|
1267
|
+
parentExternalSessionId: "ext-parent-1",
|
|
1268
|
+
childExternalSessionId: "ext-child-1",
|
|
1269
|
+
childSessionId: "session-bg-route-1",
|
|
1270
|
+
status: "created",
|
|
1271
|
+
startedAt: null,
|
|
1272
|
+
completedAt: null,
|
|
1273
|
+
error: null,
|
|
1274
|
+
};
|
|
1275
|
+
runs.set(runId, run);
|
|
1276
|
+
return run;
|
|
1277
|
+
},
|
|
1278
|
+
promptBackgroundAsync: async (input) => {
|
|
1279
|
+
const run = runs.get(input.runId);
|
|
1280
|
+
if (!run) throw new Error("Unknown run");
|
|
1281
|
+
const next = {
|
|
1282
|
+
...run,
|
|
1283
|
+
status: "running",
|
|
1284
|
+
startedAt: run.startedAt ?? new Date().toISOString(),
|
|
1285
|
+
completedAt: null,
|
|
1286
|
+
error: null,
|
|
1287
|
+
};
|
|
1288
|
+
runs.set(input.runId, next);
|
|
1289
|
+
return next;
|
|
1290
|
+
},
|
|
1291
|
+
getBackgroundStatus: async (runId) => runs.get(runId) ?? null,
|
|
1292
|
+
listBackgroundRuns: async (input) => {
|
|
1293
|
+
const values = [...runs.values()];
|
|
1294
|
+
if (!input?.parentSessionId) return values;
|
|
1295
|
+
return values.filter((run) => run.parentSessionId === input.parentSessionId);
|
|
1296
|
+
},
|
|
1297
|
+
abortBackground: async (runId) => {
|
|
1298
|
+
const run = runs.get(runId);
|
|
1299
|
+
if (!run) return false;
|
|
1300
|
+
runs.set(runId, {
|
|
1301
|
+
...run,
|
|
1302
|
+
status: "aborted",
|
|
1303
|
+
completedAt: new Date().toISOString(),
|
|
1304
|
+
});
|
|
1305
|
+
return true;
|
|
1306
|
+
},
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
const cronService = new CronService(runtime);
|
|
1310
|
+
const runService = new RunService(runtime);
|
|
1311
|
+
runService.start();
|
|
1312
|
+
const eventStream = createRuntimeEventStream({
|
|
1313
|
+
getHeartbeatSnapshot: repository.getHeartbeatSnapshot,
|
|
1314
|
+
getUsageSnapshot: repository.getUsageSnapshot,
|
|
1315
|
+
});
|
|
1316
|
+
const routes = createApiRoutes({
|
|
1317
|
+
runtime,
|
|
1318
|
+
cronService,
|
|
1319
|
+
eventStream,
|
|
1320
|
+
runService,
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
const spawnRoute = routes["/api/background"] as { POST: (req: Request) => Promise<Response> };
|
|
1324
|
+
const spawnResponse = await spawnRoute.POST(
|
|
1325
|
+
new Request("http://localhost/api/background", {
|
|
1326
|
+
method: "POST",
|
|
1327
|
+
headers: { "Content-Type": "application/json" },
|
|
1328
|
+
body: JSON.stringify({
|
|
1329
|
+
sessionId: session.id,
|
|
1330
|
+
title: "Child worker",
|
|
1331
|
+
prompt: "Run analysis",
|
|
1332
|
+
}),
|
|
1333
|
+
}),
|
|
1334
|
+
);
|
|
1335
|
+
expect(spawnResponse.status).toBe(202);
|
|
1336
|
+
const spawnPayload = (await spawnResponse.json()) as { run: { runId: string; status: string } };
|
|
1337
|
+
expect(spawnPayload.run.runId).toBe("bg-route-1");
|
|
1338
|
+
expect(spawnPayload.run.status).toBe("running");
|
|
1339
|
+
|
|
1340
|
+
const listSessionRoute = routes["/api/sessions/:id/background"] as {
|
|
1341
|
+
GET: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
1342
|
+
};
|
|
1343
|
+
const listSessionResponse = await listSessionRoute.GET(
|
|
1344
|
+
Object.assign(new Request(`http://localhost/api/sessions/${session.id}/background`), {
|
|
1345
|
+
params: { id: session.id },
|
|
1346
|
+
}),
|
|
1347
|
+
);
|
|
1348
|
+
expect(listSessionResponse.status).toBe(200);
|
|
1349
|
+
const listSessionPayload = (await listSessionResponse.json()) as { runs: Array<{ runId: string }> };
|
|
1350
|
+
expect(listSessionPayload.runs.some((run) => run.runId === "bg-route-1")).toBe(true);
|
|
1351
|
+
|
|
1352
|
+
const steerRoute = routes["/api/background/:id/steer"] as {
|
|
1353
|
+
POST: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
1354
|
+
};
|
|
1355
|
+
const steerResponse = await steerRoute.POST(
|
|
1356
|
+
Object.assign(new Request("http://localhost/api/background/bg-route-1/steer", {
|
|
1357
|
+
method: "POST",
|
|
1358
|
+
headers: { "Content-Type": "application/json" },
|
|
1359
|
+
body: JSON.stringify({ content: "Do one more pass" }),
|
|
1360
|
+
}), {
|
|
1361
|
+
params: { id: "bg-route-1" },
|
|
1362
|
+
}),
|
|
1363
|
+
);
|
|
1364
|
+
expect(steerResponse.status).toBe(202);
|
|
1365
|
+
const steerPayload = (await steerResponse.json()) as { run: { status: string } };
|
|
1366
|
+
expect(steerPayload.run.status).toBe("running");
|
|
1367
|
+
|
|
1368
|
+
const abortRoute = routes["/api/background/:id/abort"] as {
|
|
1369
|
+
POST: (req: Request & { params: { id: string } }) => Promise<Response>;
|
|
1370
|
+
};
|
|
1371
|
+
const abortResponse = await abortRoute.POST(
|
|
1372
|
+
Object.assign(new Request("http://localhost/api/background/bg-route-1/abort", { method: "POST" }), {
|
|
1373
|
+
params: { id: "bg-route-1" },
|
|
1374
|
+
}),
|
|
1375
|
+
);
|
|
1376
|
+
expect(abortResponse.status).toBe(200);
|
|
1377
|
+
const abortPayload = (await abortResponse.json()) as { aborted: boolean };
|
|
1378
|
+
expect(abortPayload.aborted).toBe(true);
|
|
1379
|
+
});
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
describe("memory validation and logging", () => {
|
|
1383
|
+
test("duplicate remember writes are rejected and logged in memory_write_events", async () => {
|
|
1384
|
+
const configPath = testConfigPath;
|
|
1385
|
+
if (!existsSync(configPath)) {
|
|
1386
|
+
throw new Error(`Expected config path to exist: ${configPath}`);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const config = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
1390
|
+
const runtime = (config.runtime ?? {}) as Record<string, unknown>;
|
|
1391
|
+
const memory = (runtime.memory ?? {}) as Record<string, unknown>;
|
|
1392
|
+
memory.embedProvider = "none";
|
|
1393
|
+
runtime.memory = memory;
|
|
1394
|
+
config.runtime = runtime;
|
|
1395
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
1396
|
+
|
|
1397
|
+
const first = await memoryService.rememberMemory({
|
|
1398
|
+
source: "system",
|
|
1399
|
+
content: "Persist this memory value",
|
|
1400
|
+
sessionId: "main",
|
|
1401
|
+
confidence: 0.95,
|
|
1402
|
+
});
|
|
1403
|
+
expect(first.accepted).toBe(true);
|
|
1404
|
+
|
|
1405
|
+
const result = await memoryService.rememberMemory({
|
|
1406
|
+
source: "system",
|
|
1407
|
+
content: "Persist this memory value",
|
|
1408
|
+
sessionId: "main",
|
|
1409
|
+
confidence: 0.95,
|
|
1410
|
+
});
|
|
1411
|
+
|
|
1412
|
+
expect(result.accepted).toBe(false);
|
|
1413
|
+
const events = await memoryService.listMemoryWriteEvents(5);
|
|
1414
|
+
expect(events.length).toBeGreaterThan(0);
|
|
1415
|
+
expect(events.some(event => event.status === "rejected" && event.sessionId === "main")).toBe(true);
|
|
1416
|
+
});
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
describe("cron validation and retries", () => {
|
|
1420
|
+
test("invalid runMode requirements are rejected", async () => {
|
|
1421
|
+
const runtime = createRuntimeStub(async () => ({ sessionId: "main", messages: [] }));
|
|
1422
|
+
const cronService = new CronService(runtime);
|
|
1423
|
+
|
|
1424
|
+
await expect(
|
|
1425
|
+
cronService.createJob({
|
|
1426
|
+
name: "invalid-background",
|
|
1427
|
+
scheduleKind: "every",
|
|
1428
|
+
everyMs: 5_000,
|
|
1429
|
+
runMode: "background",
|
|
1430
|
+
}),
|
|
1431
|
+
).rejects.toThrow("runMode=background requires conditionModulePath");
|
|
1432
|
+
|
|
1433
|
+
await expect(
|
|
1434
|
+
cronService.createJob({
|
|
1435
|
+
name: "invalid-agent",
|
|
1436
|
+
scheduleKind: "every",
|
|
1437
|
+
everyMs: 5_000,
|
|
1438
|
+
runMode: "agent",
|
|
1439
|
+
}),
|
|
1440
|
+
).rejects.toThrow("runMode=agent requires agentPromptTemplate");
|
|
1441
|
+
|
|
1442
|
+
await expect(
|
|
1443
|
+
cronService.createJob({
|
|
1444
|
+
name: "invalid-conditional",
|
|
1445
|
+
scheduleKind: "every",
|
|
1446
|
+
everyMs: 5_000,
|
|
1447
|
+
runMode: "conditional_agent",
|
|
1448
|
+
}),
|
|
1449
|
+
).rejects.toThrow("runMode=conditional_agent requires conditionModulePath");
|
|
1450
|
+
|
|
1451
|
+
await expect(
|
|
1452
|
+
cronService.createJob({
|
|
1453
|
+
name: "invalid-handler-key",
|
|
1454
|
+
scheduleKind: "every",
|
|
1455
|
+
everyMs: 5_000,
|
|
1456
|
+
runMode: "background",
|
|
1457
|
+
conditionModulePath: "cron/check-stock.ts",
|
|
1458
|
+
handlerKey: "memory.maintenance",
|
|
1459
|
+
} as never),
|
|
1460
|
+
).rejects.toThrow("handlerKey is no longer supported");
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
test("failed agent jobs transition from failed to dead after maxAttempts", async () => {
|
|
1464
|
+
const runtime = createRuntimeStub(async () => {
|
|
1465
|
+
throw new Error("forced runtime failure");
|
|
1466
|
+
});
|
|
1467
|
+
const cronService = new CronService(runtime);
|
|
1468
|
+
|
|
1469
|
+
const job = await cronService.createJob({
|
|
1470
|
+
name: "retry-agent",
|
|
1471
|
+
scheduleKind: "every",
|
|
1472
|
+
everyMs: 10_000,
|
|
1473
|
+
runMode: "agent",
|
|
1474
|
+
agentPromptTemplate: "Run check",
|
|
1475
|
+
maxAttempts: 2,
|
|
1476
|
+
retryBackoffMs: 1_000,
|
|
1477
|
+
payload: { sessionId: "main" },
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
const queued = await cronService.runJobNow(job.id);
|
|
1481
|
+
expect(queued.queued).toBe(true);
|
|
1482
|
+
expect(queued.instanceId).toBeTruthy();
|
|
1483
|
+
|
|
1484
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1485
|
+
let instances = await cronService.listInstances({ jobId: job.id, limit: 1 });
|
|
1486
|
+
expect(instances[0]?.state).toBe("failed");
|
|
1487
|
+
expect(instances[0]?.attempt).toBe(1);
|
|
1488
|
+
|
|
1489
|
+
const instanceId = instances[0]?.id;
|
|
1490
|
+
expect(instanceId).toBeTruthy();
|
|
1491
|
+
if (!instanceId) return;
|
|
1492
|
+
sqlite.query("UPDATE cron_job_instances SET next_attempt_at = ?2 WHERE id = ?1").run(instanceId, 0);
|
|
1493
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1494
|
+
|
|
1495
|
+
instances = await cronService.listInstances({ jobId: job.id, limit: 1 });
|
|
1496
|
+
expect(instances[0]?.state).toBe("dead");
|
|
1497
|
+
expect(instances[0]?.attempt).toBe(2);
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
test("conditional_agent module can decide to invoke the agent with per-run context", async () => {
|
|
1501
|
+
const captured: Array<{ content: string; sessionId: string }> = [];
|
|
1502
|
+
let spawnCount = 0;
|
|
1503
|
+
const cronThread = repository.createSession({ title: "Cron Stock Thread" });
|
|
1504
|
+
const runtime = {
|
|
1505
|
+
...createRuntimeStub(async input => {
|
|
1506
|
+
captured.push({ content: input.content, sessionId: input.sessionId });
|
|
1507
|
+
return {
|
|
1508
|
+
sessionId: input.sessionId,
|
|
1509
|
+
messages: [{ id: "assistant-1", role: "assistant", content: "noted", at: new Date().toISOString() }],
|
|
1510
|
+
};
|
|
1511
|
+
}),
|
|
1512
|
+
spawnBackgroundSession: async () => {
|
|
1513
|
+
spawnCount += 1;
|
|
1514
|
+
sqlite
|
|
1515
|
+
.query(
|
|
1516
|
+
`
|
|
1517
|
+
INSERT INTO runtime_session_bindings (runtime, session_id, external_session_id, updated_at)
|
|
1518
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
1519
|
+
ON CONFLICT(runtime, session_id) DO UPDATE SET
|
|
1520
|
+
external_session_id = excluded.external_session_id,
|
|
1521
|
+
updated_at = excluded.updated_at
|
|
1522
|
+
`,
|
|
1523
|
+
)
|
|
1524
|
+
.run("opencode", cronThread.id, `ext-cron-${spawnCount}`, Date.now());
|
|
1525
|
+
return {
|
|
1526
|
+
runId: `bg-cron-${spawnCount}`,
|
|
1527
|
+
parentSessionId: "main",
|
|
1528
|
+
parentExternalSessionId: "ext-main",
|
|
1529
|
+
childExternalSessionId: `ext-cron-${spawnCount}`,
|
|
1530
|
+
childSessionId: cronThread.id,
|
|
1531
|
+
status: "created",
|
|
1532
|
+
startedAt: null,
|
|
1533
|
+
completedAt: null,
|
|
1534
|
+
error: null,
|
|
1535
|
+
};
|
|
1536
|
+
},
|
|
1537
|
+
};
|
|
1538
|
+
const cronService = new CronService(runtime);
|
|
1539
|
+
|
|
1540
|
+
mkdirSync(path.join(testWorkspacePath, "cron"), { recursive: true });
|
|
1541
|
+
writeFileSync(
|
|
1542
|
+
path.join(testWorkspacePath, "cron", "stock-alert.ts"),
|
|
1543
|
+
[
|
|
1544
|
+
"export default async function run(ctx) {",
|
|
1545
|
+
" return {",
|
|
1546
|
+
" status: 'ok',",
|
|
1547
|
+
" summary: 'movement detected',",
|
|
1548
|
+
" invokeAgent: {",
|
|
1549
|
+
" shouldInvoke: true,",
|
|
1550
|
+
" prompt: 'Stock {{symbol}} moved {{movePct}}% in {{windowMin}}m',",
|
|
1551
|
+
" context: { symbol: ctx.payload.symbol, movePct: ctx.payload.movePct, windowMin: 10 },",
|
|
1552
|
+
" },",
|
|
1553
|
+
" };",
|
|
1554
|
+
"}",
|
|
1555
|
+
].join("\n"),
|
|
1556
|
+
);
|
|
1557
|
+
|
|
1558
|
+
const job = await cronService.createJob({
|
|
1559
|
+
name: "stock-alert",
|
|
1560
|
+
scheduleKind: "every",
|
|
1561
|
+
everyMs: 10_000,
|
|
1562
|
+
runMode: "conditional_agent",
|
|
1563
|
+
conditionModulePath: "cron/stock-alert.ts",
|
|
1564
|
+
payload: {
|
|
1565
|
+
symbol: "AAPL",
|
|
1566
|
+
movePct: 3.4,
|
|
1567
|
+
},
|
|
1568
|
+
});
|
|
1569
|
+
|
|
1570
|
+
const queued = await cronService.runJobNow(job.id);
|
|
1571
|
+
expect(queued.queued).toBe(true);
|
|
1572
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1573
|
+
|
|
1574
|
+
const instances = await cronService.listInstances({ jobId: job.id, limit: 1 });
|
|
1575
|
+
expect(instances[0]?.state).toBe("completed");
|
|
1576
|
+
expect(instances[0]?.agentInvoked).toBe(true);
|
|
1577
|
+
expect(captured.length).toBe(1);
|
|
1578
|
+
expect(captured[0]?.content).toContain("Stock AAPL moved 3.4% in 10m");
|
|
1579
|
+
expect(captured[0]?.sessionId).toBe(cronThread.id);
|
|
1580
|
+
expect(spawnCount).toBe(1);
|
|
1581
|
+
const updatedJob = await cronService.getJob(job.id);
|
|
1582
|
+
expect(updatedJob?.threadSessionId).toBe(cronThread.id);
|
|
1583
|
+
|
|
1584
|
+
const instanceId = instances[0]?.id;
|
|
1585
|
+
if (!instanceId) return;
|
|
1586
|
+
const steps = await cronService.listSteps(instanceId);
|
|
1587
|
+
expect(steps.some(step => step.stepKind === "conditional_agent" && step.status === "completed")).toBe(true);
|
|
1588
|
+
expect(steps.some(step => step.stepKind === "agent" && step.status === "completed")).toBe(true);
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
test("background cron jobs reject symlinked module paths that escape the workspace", async () => {
|
|
1592
|
+
const runtime = createRuntimeStub(async () => ({ sessionId: "main", messages: [] }));
|
|
1593
|
+
const cronService = new CronService(runtime);
|
|
1594
|
+
|
|
1595
|
+
mkdirSync(path.join(testWorkspacePath, "cron"), { recursive: true });
|
|
1596
|
+
const outsideModulePath = path.join(testRoot, "outside-cron-module.ts");
|
|
1597
|
+
writeFileSync(
|
|
1598
|
+
outsideModulePath,
|
|
1599
|
+
"export default async function run() { return { status: 'ok', summary: 'escaped' }; }\n",
|
|
1600
|
+
"utf8",
|
|
1601
|
+
);
|
|
1602
|
+
symlinkSync(outsideModulePath, path.join(testWorkspacePath, "cron", "escape.ts"));
|
|
1603
|
+
|
|
1604
|
+
const job = await cronService.createJob({
|
|
1605
|
+
name: "escaped-module",
|
|
1606
|
+
scheduleKind: "every",
|
|
1607
|
+
everyMs: 10_000,
|
|
1608
|
+
runMode: "background",
|
|
1609
|
+
conditionModulePath: "cron/escape.ts",
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
await cronService.runJobNow(job.id);
|
|
1613
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1614
|
+
|
|
1615
|
+
const instances = await cronService.listInstances({ jobId: job.id, limit: 1 });
|
|
1616
|
+
expect(instances[0]?.state).toBe("failed");
|
|
1617
|
+
expect(instances[0]?.error).toMatchObject({
|
|
1618
|
+
message: "conditionModulePath escapes the runtime workspace directory",
|
|
1619
|
+
});
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
test("upsertJob is idempotent for stable cron IDs", async () => {
|
|
1623
|
+
const runtime = createRuntimeStub(async () => ({ sessionId: "main", messages: [] }));
|
|
1624
|
+
const cronService = new CronService(runtime);
|
|
1625
|
+
|
|
1626
|
+
const first = await cronService.upsertJob({
|
|
1627
|
+
id: "stable-stock-alert",
|
|
1628
|
+
name: "stable-stock-alert",
|
|
1629
|
+
scheduleKind: "every",
|
|
1630
|
+
everyMs: 5_000,
|
|
1631
|
+
runMode: "agent",
|
|
1632
|
+
agentPromptTemplate: "Check status",
|
|
1633
|
+
});
|
|
1634
|
+
const second = await cronService.upsertJob({
|
|
1635
|
+
id: "stable-stock-alert",
|
|
1636
|
+
name: "stable-stock-alert-updated",
|
|
1637
|
+
scheduleKind: "every",
|
|
1638
|
+
everyMs: 15_000,
|
|
1639
|
+
runMode: "agent",
|
|
1640
|
+
agentPromptTemplate: "Check status",
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
expect(first.created).toBe(true);
|
|
1644
|
+
expect(second.created).toBe(false);
|
|
1645
|
+
|
|
1646
|
+
const jobs = await cronService.listJobs();
|
|
1647
|
+
const matching = jobs.filter(job => job.id === "stable-stock-alert");
|
|
1648
|
+
expect(matching.length).toBe(1);
|
|
1649
|
+
expect(matching[0]?.name).toBe("stable-stock-alert-updated");
|
|
1650
|
+
expect(matching[0]?.everyMs).toBe(15_000);
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
test("conditional_agent instance reports agentInvoked=false when no escalation occurs", async () => {
|
|
1654
|
+
let spawnCount = 0;
|
|
1655
|
+
const runtime = {
|
|
1656
|
+
...createRuntimeStub(async () => ({ sessionId: "main", messages: [] })),
|
|
1657
|
+
spawnBackgroundSession: async () => {
|
|
1658
|
+
spawnCount += 1;
|
|
1659
|
+
const child = repository.createSession({ title: "Unused Cron Thread" });
|
|
1660
|
+
return {
|
|
1661
|
+
runId: `bg-cron-${spawnCount}`,
|
|
1662
|
+
parentSessionId: "main",
|
|
1663
|
+
parentExternalSessionId: "ext-main",
|
|
1664
|
+
childExternalSessionId: `ext-cron-${spawnCount}`,
|
|
1665
|
+
childSessionId: child.id,
|
|
1666
|
+
status: "created",
|
|
1667
|
+
startedAt: null,
|
|
1668
|
+
completedAt: null,
|
|
1669
|
+
error: null,
|
|
1670
|
+
};
|
|
1671
|
+
},
|
|
1672
|
+
};
|
|
1673
|
+
const cronService = new CronService(runtime);
|
|
1674
|
+
|
|
1675
|
+
mkdirSync(path.join(testWorkspacePath, "cron"), { recursive: true });
|
|
1676
|
+
writeFileSync(
|
|
1677
|
+
path.join(testWorkspacePath, "cron", "no-invoke.ts"),
|
|
1678
|
+
[
|
|
1679
|
+
"export default async function run() {",
|
|
1680
|
+
" return { status: 'ok', summary: 'no escalation', invokeAgent: { shouldInvoke: false } };",
|
|
1681
|
+
"}",
|
|
1682
|
+
].join("\n"),
|
|
1683
|
+
);
|
|
1684
|
+
|
|
1685
|
+
const job = await cronService.createJob({
|
|
1686
|
+
name: "no-invoke",
|
|
1687
|
+
scheduleKind: "every",
|
|
1688
|
+
everyMs: 10_000,
|
|
1689
|
+
runMode: "conditional_agent",
|
|
1690
|
+
conditionModulePath: "cron/no-invoke.ts",
|
|
1691
|
+
payload: {},
|
|
1692
|
+
});
|
|
1693
|
+
|
|
1694
|
+
const queued = await cronService.runJobNow(job.id);
|
|
1695
|
+
expect(queued.queued).toBe(true);
|
|
1696
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1697
|
+
|
|
1698
|
+
const instances = await cronService.listInstances({ jobId: job.id, limit: 1 });
|
|
1699
|
+
expect(instances[0]?.state).toBe("completed");
|
|
1700
|
+
expect(instances[0]?.agentInvoked).toBe(false);
|
|
1701
|
+
expect(spawnCount).toBe(1);
|
|
1702
|
+
const updatedJob = await cronService.getJob(job.id);
|
|
1703
|
+
expect(updatedJob?.threadSessionId).toBeTruthy();
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
test("background module jobs get a durable cron thread and stay non-agent", async () => {
|
|
1707
|
+
const captured: string[] = [];
|
|
1708
|
+
let spawnCount = 0;
|
|
1709
|
+
const cronThread = repository.createSession({ title: "Background Cron Thread" });
|
|
1710
|
+
const runtime = {
|
|
1711
|
+
...createRuntimeStub(async input => {
|
|
1712
|
+
captured.push(input.sessionId);
|
|
1713
|
+
return { sessionId: input.sessionId, messages: [] };
|
|
1714
|
+
}),
|
|
1715
|
+
spawnBackgroundSession: async () => {
|
|
1716
|
+
spawnCount += 1;
|
|
1717
|
+
sqlite
|
|
1718
|
+
.query(
|
|
1719
|
+
`
|
|
1720
|
+
INSERT INTO runtime_session_bindings (runtime, session_id, external_session_id, updated_at)
|
|
1721
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
1722
|
+
ON CONFLICT(runtime, session_id) DO UPDATE SET
|
|
1723
|
+
external_session_id = excluded.external_session_id,
|
|
1724
|
+
updated_at = excluded.updated_at
|
|
1725
|
+
`,
|
|
1726
|
+
)
|
|
1727
|
+
.run("opencode", cronThread.id, `ext-bg-${spawnCount}`, Date.now());
|
|
1728
|
+
return {
|
|
1729
|
+
runId: `bg-cron-${spawnCount}`,
|
|
1730
|
+
parentSessionId: "main",
|
|
1731
|
+
parentExternalSessionId: "ext-main",
|
|
1732
|
+
childExternalSessionId: `ext-bg-${spawnCount}`,
|
|
1733
|
+
childSessionId: cronThread.id,
|
|
1734
|
+
status: "created",
|
|
1735
|
+
startedAt: null,
|
|
1736
|
+
completedAt: null,
|
|
1737
|
+
error: null,
|
|
1738
|
+
};
|
|
1739
|
+
},
|
|
1740
|
+
};
|
|
1741
|
+
const cronService = new CronService(runtime);
|
|
1742
|
+
|
|
1743
|
+
mkdirSync(path.join(testWorkspacePath, "cron"), { recursive: true });
|
|
1744
|
+
writeFileSync(
|
|
1745
|
+
path.join(testWorkspacePath, "cron", "background-check.ts"),
|
|
1746
|
+
[
|
|
1747
|
+
"export default async function run(ctx) {",
|
|
1748
|
+
" return { status: 'ok', summary: `checked ${ctx.payload.symbol}` };",
|
|
1749
|
+
"}",
|
|
1750
|
+
].join("\n"),
|
|
1751
|
+
);
|
|
1752
|
+
|
|
1753
|
+
const job = await cronService.createJob({
|
|
1754
|
+
name: "background-check",
|
|
1755
|
+
scheduleKind: "every",
|
|
1756
|
+
everyMs: 10_000,
|
|
1757
|
+
runMode: "background",
|
|
1758
|
+
conditionModulePath: "cron/background-check.ts",
|
|
1759
|
+
payload: { symbol: "AAPL" },
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
await cronService.runJobNow(job.id);
|
|
1763
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1764
|
+
|
|
1765
|
+
const instances = await cronService.listInstances({ jobId: job.id, limit: 1 });
|
|
1766
|
+
expect(instances[0]?.state).toBe("completed");
|
|
1767
|
+
expect(instances[0]?.agentInvoked).toBe(false);
|
|
1768
|
+
expect(spawnCount).toBe(1);
|
|
1769
|
+
expect(captured).toEqual([]);
|
|
1770
|
+
const updatedJob = await cronService.getJob(job.id);
|
|
1771
|
+
expect(updatedJob?.threadSessionId).toBe(cronThread.id);
|
|
1772
|
+
});
|
|
1773
|
+
|
|
1774
|
+
test("background module jobs reject invokeAgent results", async () => {
|
|
1775
|
+
let spawnCount = 0;
|
|
1776
|
+
const runtime = {
|
|
1777
|
+
...createRuntimeStub(async () => ({ sessionId: "main", messages: [] })),
|
|
1778
|
+
spawnBackgroundSession: async () => {
|
|
1779
|
+
spawnCount += 1;
|
|
1780
|
+
const child = repository.createSession({ title: "Background Reject Thread" });
|
|
1781
|
+
return {
|
|
1782
|
+
runId: `bg-cron-${spawnCount}`,
|
|
1783
|
+
parentSessionId: "main",
|
|
1784
|
+
parentExternalSessionId: "ext-main",
|
|
1785
|
+
childExternalSessionId: `ext-bg-reject-${spawnCount}`,
|
|
1786
|
+
childSessionId: child.id,
|
|
1787
|
+
status: "created",
|
|
1788
|
+
startedAt: null,
|
|
1789
|
+
completedAt: null,
|
|
1790
|
+
error: null,
|
|
1791
|
+
};
|
|
1792
|
+
},
|
|
1793
|
+
};
|
|
1794
|
+
const cronService = new CronService(runtime);
|
|
1795
|
+
|
|
1796
|
+
mkdirSync(path.join(testWorkspacePath, "cron"), { recursive: true });
|
|
1797
|
+
writeFileSync(
|
|
1798
|
+
path.join(testWorkspacePath, "cron", "background-invalid.ts"),
|
|
1799
|
+
[
|
|
1800
|
+
"export default async function run() {",
|
|
1801
|
+
" return {",
|
|
1802
|
+
" status: 'ok',",
|
|
1803
|
+
" summary: 'invalid',",
|
|
1804
|
+
" invokeAgent: { shouldInvoke: true, prompt: 'should not happen' },",
|
|
1805
|
+
" };",
|
|
1806
|
+
"}",
|
|
1807
|
+
].join("\n"),
|
|
1808
|
+
);
|
|
1809
|
+
|
|
1810
|
+
const job = await cronService.createJob({
|
|
1811
|
+
name: "background-invalid",
|
|
1812
|
+
scheduleKind: "every",
|
|
1813
|
+
everyMs: 10_000,
|
|
1814
|
+
runMode: "background",
|
|
1815
|
+
conditionModulePath: "cron/background-invalid.ts",
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
await cronService.runJobNow(job.id);
|
|
1819
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1820
|
+
|
|
1821
|
+
const instances = await cronService.listInstances({ jobId: job.id, limit: 1 });
|
|
1822
|
+
expect(instances[0]?.state).toBe("failed");
|
|
1823
|
+
expect(instances[0]?.error).toMatchObject({ message: "runMode=background does not allow invokeAgent" });
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
test("agent jobs reuse one durable thread per cron definition", async () => {
|
|
1827
|
+
const captured: string[] = [];
|
|
1828
|
+
let spawnCount = 0;
|
|
1829
|
+
const cronThread = repository.createSession({ title: "Stable Cron Thread" });
|
|
1830
|
+
const runtime = {
|
|
1831
|
+
...createRuntimeStub(async input => {
|
|
1832
|
+
captured.push(input.sessionId);
|
|
1833
|
+
return {
|
|
1834
|
+
sessionId: input.sessionId,
|
|
1835
|
+
messages: [{ id: "assistant-1", role: "assistant", content: "done", at: new Date().toISOString() }],
|
|
1836
|
+
};
|
|
1837
|
+
}),
|
|
1838
|
+
spawnBackgroundSession: async () => {
|
|
1839
|
+
spawnCount += 1;
|
|
1840
|
+
sqlite
|
|
1841
|
+
.query(
|
|
1842
|
+
`
|
|
1843
|
+
INSERT INTO runtime_session_bindings (runtime, session_id, external_session_id, updated_at)
|
|
1844
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
1845
|
+
ON CONFLICT(runtime, session_id) DO UPDATE SET
|
|
1846
|
+
external_session_id = excluded.external_session_id,
|
|
1847
|
+
updated_at = excluded.updated_at
|
|
1848
|
+
`,
|
|
1849
|
+
)
|
|
1850
|
+
.run("opencode", cronThread.id, "ext-cron-thread", Date.now());
|
|
1851
|
+
return {
|
|
1852
|
+
runId: `bg-cron-${spawnCount}`,
|
|
1853
|
+
parentSessionId: "main",
|
|
1854
|
+
parentExternalSessionId: "ext-main",
|
|
1855
|
+
childExternalSessionId: `ext-cron-${spawnCount}`,
|
|
1856
|
+
childSessionId: cronThread.id,
|
|
1857
|
+
status: "created",
|
|
1858
|
+
startedAt: null,
|
|
1859
|
+
completedAt: null,
|
|
1860
|
+
error: null,
|
|
1861
|
+
};
|
|
1862
|
+
},
|
|
1863
|
+
};
|
|
1864
|
+
const cronService = new CronService(runtime);
|
|
1865
|
+
|
|
1866
|
+
const job = await cronService.createJob({
|
|
1867
|
+
name: "stable-thread",
|
|
1868
|
+
scheduleKind: "every",
|
|
1869
|
+
everyMs: 10_000,
|
|
1870
|
+
runMode: "agent",
|
|
1871
|
+
agentPromptTemplate: "Check status",
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
await cronService.runJobNow(job.id);
|
|
1875
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1876
|
+
await cronService.runJobNow(job.id);
|
|
1877
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1878
|
+
|
|
1879
|
+
expect(spawnCount).toBe(1);
|
|
1880
|
+
expect(captured).toEqual([cronThread.id, cronThread.id]);
|
|
1881
|
+
const updatedJob = await cronService.getJob(job.id);
|
|
1882
|
+
expect(updatedJob?.threadSessionId).toBe(cronThread.id);
|
|
1883
|
+
});
|
|
1884
|
+
|
|
1885
|
+
test("heartbeat-style agent jobs use a cron-owned thread and sync that thread model from main", async () => {
|
|
1886
|
+
const captured: Array<{
|
|
1887
|
+
sessionId: string;
|
|
1888
|
+
content: string;
|
|
1889
|
+
agent?: string;
|
|
1890
|
+
}> = [];
|
|
1891
|
+
let spawnCount = 0;
|
|
1892
|
+
const runtime = {
|
|
1893
|
+
...createRuntimeStub(async input => {
|
|
1894
|
+
captured.push(input);
|
|
1895
|
+
return {
|
|
1896
|
+
sessionId: input.sessionId,
|
|
1897
|
+
messages: [{ id: "assistant-1", role: "assistant", content: "HEARTBEAT_OK", at: new Date().toISOString() }],
|
|
1898
|
+
};
|
|
1899
|
+
}),
|
|
1900
|
+
spawnBackgroundSession: async (input: {
|
|
1901
|
+
parentSessionId: string;
|
|
1902
|
+
title?: string;
|
|
1903
|
+
requestedBy?: string;
|
|
1904
|
+
prompt?: string;
|
|
1905
|
+
}) => {
|
|
1906
|
+
spawnCount += 1;
|
|
1907
|
+
const child = repository.createSession({
|
|
1908
|
+
title: "Stale Heartbeat Thread",
|
|
1909
|
+
model: "opencode/stale-model",
|
|
1910
|
+
});
|
|
1911
|
+
sqlite
|
|
1912
|
+
.query(
|
|
1913
|
+
`
|
|
1914
|
+
INSERT INTO runtime_session_bindings (runtime, session_id, external_session_id, updated_at)
|
|
1915
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
1916
|
+
ON CONFLICT(runtime, session_id) DO UPDATE SET
|
|
1917
|
+
external_session_id = excluded.external_session_id,
|
|
1918
|
+
updated_at = excluded.updated_at
|
|
1919
|
+
`,
|
|
1920
|
+
)
|
|
1921
|
+
.run("opencode", child.id, `ext-heartbeat-${spawnCount}`, Date.now());
|
|
1922
|
+
return {
|
|
1923
|
+
runId: `bg-heartbeat-${spawnCount}`,
|
|
1924
|
+
parentSessionId: input.parentSessionId,
|
|
1925
|
+
parentExternalSessionId: "ext-main",
|
|
1926
|
+
childExternalSessionId: `ext-heartbeat-${spawnCount}`,
|
|
1927
|
+
childSessionId: child.id,
|
|
1928
|
+
status: "created",
|
|
1929
|
+
startedAt: null,
|
|
1930
|
+
completedAt: null,
|
|
1931
|
+
error: null,
|
|
1932
|
+
};
|
|
1933
|
+
},
|
|
1934
|
+
};
|
|
1935
|
+
repository.setSessionModel("main", "opencode/follow-main");
|
|
1936
|
+
|
|
1937
|
+
const cronService = new CronService(runtime);
|
|
1938
|
+
const job = await cronService.createJob({
|
|
1939
|
+
name: "heartbeat-threaded",
|
|
1940
|
+
scheduleKind: "every",
|
|
1941
|
+
everyMs: 10_000,
|
|
1942
|
+
runMode: "agent",
|
|
1943
|
+
agentPromptTemplate: "Read HEARTBEAT.md if it exists (workspace context).",
|
|
1944
|
+
payload: {
|
|
1945
|
+
agentId: "build",
|
|
1946
|
+
},
|
|
1947
|
+
});
|
|
1948
|
+
|
|
1949
|
+
await cronService.runJobNow(job.id);
|
|
1950
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
1951
|
+
|
|
1952
|
+
expect(spawnCount).toBe(1);
|
|
1953
|
+
expect(captured).toHaveLength(1);
|
|
1954
|
+
expect(captured[0]?.sessionId).not.toBe("main");
|
|
1955
|
+
expect(captured[0]?.agent).toBe("build");
|
|
1956
|
+
|
|
1957
|
+
const updatedJob = await cronService.getJob(job.id);
|
|
1958
|
+
expect(updatedJob?.threadSessionId).toBeTruthy();
|
|
1959
|
+
const threadSessionId = updatedJob?.threadSessionId;
|
|
1960
|
+
if (!threadSessionId) return;
|
|
1961
|
+
|
|
1962
|
+
expect(captured[0]?.sessionId).toBe(threadSessionId);
|
|
1963
|
+
expect(repository.getSessionById(threadSessionId)?.model).toBe("opencode/follow-main");
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
test("cron threads can explicitly notify the main thread", async () => {
|
|
1967
|
+
const captured: Array<{ sessionId: string; content: string; metadata?: Record<string, unknown> }> = [];
|
|
1968
|
+
let spawnCount = 0;
|
|
1969
|
+
const cronThread = repository.createSession({ title: "Notify Cron Thread" });
|
|
1970
|
+
const runtime = {
|
|
1971
|
+
...createRuntimeStub(async input => {
|
|
1972
|
+
captured.push(input);
|
|
1973
|
+
return {
|
|
1974
|
+
sessionId: input.sessionId,
|
|
1975
|
+
messages: [{ id: "assistant-1", role: "assistant", content: "done", at: new Date().toISOString() }],
|
|
1976
|
+
};
|
|
1977
|
+
}),
|
|
1978
|
+
spawnBackgroundSession: async () => {
|
|
1979
|
+
spawnCount += 1;
|
|
1980
|
+
return {
|
|
1981
|
+
runId: `bg-cron-${spawnCount}`,
|
|
1982
|
+
parentSessionId: "main",
|
|
1983
|
+
parentExternalSessionId: "ext-main",
|
|
1984
|
+
childExternalSessionId: "ext-cron-thread",
|
|
1985
|
+
childSessionId: cronThread.id,
|
|
1986
|
+
status: "created",
|
|
1987
|
+
startedAt: null,
|
|
1988
|
+
completedAt: null,
|
|
1989
|
+
error: null,
|
|
1990
|
+
};
|
|
1991
|
+
},
|
|
1992
|
+
};
|
|
1993
|
+
const cronService = new CronService(runtime);
|
|
1994
|
+
|
|
1995
|
+
const job = await cronService.createJob({
|
|
1996
|
+
name: "needs-attention",
|
|
1997
|
+
scheduleKind: "every",
|
|
1998
|
+
everyMs: 10_000,
|
|
1999
|
+
runMode: "agent",
|
|
2000
|
+
agentPromptTemplate: "Review state",
|
|
2001
|
+
});
|
|
2002
|
+
await cronService.runJobNow(job.id);
|
|
2003
|
+
await (cronService as unknown as { workerTick: () => Promise<void> }).workerTick();
|
|
2004
|
+
sqlite
|
|
2005
|
+
.query(
|
|
2006
|
+
`
|
|
2007
|
+
INSERT INTO runtime_session_bindings (runtime, session_id, external_session_id, updated_at)
|
|
2008
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
2009
|
+
ON CONFLICT(runtime, session_id) DO UPDATE SET
|
|
2010
|
+
external_session_id = excluded.external_session_id,
|
|
2011
|
+
updated_at = excluded.updated_at
|
|
2012
|
+
`,
|
|
2013
|
+
)
|
|
2014
|
+
.run("opencode", cronThread.id, "ext-cron-thread", Date.now());
|
|
2015
|
+
|
|
2016
|
+
const result = await cronService.notifyMainThread({
|
|
2017
|
+
runtimeSessionId: "ext-cron-thread",
|
|
2018
|
+
prompt: "Please ask the user whether to continue.",
|
|
2019
|
+
severity: "warn",
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
expect(result).toEqual({
|
|
2023
|
+
delivered: true,
|
|
2024
|
+
cronJobId: job.id,
|
|
2025
|
+
threadSessionId: cronThread.id,
|
|
2026
|
+
sourceKind: "cron",
|
|
2027
|
+
});
|
|
2028
|
+
expect(captured.at(-1)?.sessionId).toBe("main");
|
|
2029
|
+
expect(captured.at(-1)?.content).toContain("Cron escalation from needs-attention");
|
|
2030
|
+
expect(captured.at(-1)?.content).toContain("Please ask the user whether to continue.");
|
|
2031
|
+
expect(captured.at(-1)?.metadata).toMatchObject({
|
|
2032
|
+
source: "cron",
|
|
2033
|
+
cronJobId: job.id,
|
|
2034
|
+
cronThreadSessionId: cronThread.id,
|
|
2035
|
+
severity: "warn",
|
|
2036
|
+
});
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
test("heartbeat threads can explicitly notify the main thread", async () => {
|
|
2040
|
+
const { patchHeartbeatRuntimeState } = (await import("../heartbeat/state")) as unknown as {
|
|
2041
|
+
patchHeartbeatRuntimeState: (patch: {
|
|
2042
|
+
sessionId?: string | null;
|
|
2043
|
+
backgroundRunId?: string | null;
|
|
2044
|
+
parentSessionId?: string | null;
|
|
2045
|
+
externalSessionId?: string | null;
|
|
2046
|
+
}) => void;
|
|
2047
|
+
};
|
|
2048
|
+
const captured: Array<{ sessionId: string; content: string; metadata?: Record<string, unknown> }> = [];
|
|
2049
|
+
const heartbeatThread = repository.createSession({ title: "Heartbeat" });
|
|
2050
|
+
patchHeartbeatRuntimeState({
|
|
2051
|
+
sessionId: heartbeatThread.id,
|
|
2052
|
+
backgroundRunId: "bg-heartbeat-1",
|
|
2053
|
+
parentSessionId: "main",
|
|
2054
|
+
externalSessionId: "ext-heartbeat-thread",
|
|
2055
|
+
});
|
|
2056
|
+
sqlite
|
|
2057
|
+
.query(
|
|
2058
|
+
`
|
|
2059
|
+
INSERT INTO runtime_session_bindings (runtime, session_id, external_session_id, updated_at)
|
|
2060
|
+
VALUES (?1, ?2, ?3, ?4)
|
|
2061
|
+
ON CONFLICT(runtime, session_id) DO UPDATE SET
|
|
2062
|
+
external_session_id = excluded.external_session_id,
|
|
2063
|
+
updated_at = excluded.updated_at
|
|
2064
|
+
`,
|
|
2065
|
+
)
|
|
2066
|
+
.run("opencode", heartbeatThread.id, "ext-heartbeat-thread", Date.now());
|
|
2067
|
+
|
|
2068
|
+
const runtime = createRuntimeStub(async input => {
|
|
2069
|
+
captured.push(input);
|
|
2070
|
+
return {
|
|
2071
|
+
sessionId: input.sessionId,
|
|
2072
|
+
messages: [{ id: "assistant-1", role: "assistant", content: "done", at: new Date().toISOString() }],
|
|
2073
|
+
};
|
|
2074
|
+
});
|
|
2075
|
+
const cronService = new CronService(runtime);
|
|
2076
|
+
|
|
2077
|
+
const result = await cronService.notifyMainThread({
|
|
2078
|
+
runtimeSessionId: "ext-heartbeat-thread",
|
|
2079
|
+
prompt: "User attention is needed.",
|
|
2080
|
+
severity: "critical",
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
expect(result).toEqual({
|
|
2084
|
+
delivered: true,
|
|
2085
|
+
threadSessionId: heartbeatThread.id,
|
|
2086
|
+
sourceKind: "heartbeat",
|
|
2087
|
+
});
|
|
2088
|
+
expect(captured.at(-1)?.sessionId).toBe("main");
|
|
2089
|
+
expect(captured.at(-1)?.content).toContain("Heartbeat escalation");
|
|
2090
|
+
expect(captured.at(-1)?.content).toContain("User attention is needed.");
|
|
2091
|
+
expect(captured.at(-1)?.metadata).toMatchObject({
|
|
2092
|
+
source: "heartbeat",
|
|
2093
|
+
heartbeatThreadSessionId: heartbeatThread.id,
|
|
2094
|
+
severity: "critical",
|
|
2095
|
+
});
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
test("jobs can be disabled and re-enabled without deletion", async () => {
|
|
2099
|
+
const runtime = createRuntimeStub(async () => ({ sessionId: "main", messages: [] }));
|
|
2100
|
+
const cronService = new CronService(runtime);
|
|
2101
|
+
|
|
2102
|
+
const job = await cronService.createJob({
|
|
2103
|
+
name: "toggle-me",
|
|
2104
|
+
scheduleKind: "every",
|
|
2105
|
+
everyMs: 5_000,
|
|
2106
|
+
runMode: "agent",
|
|
2107
|
+
agentPromptTemplate: "noop",
|
|
2108
|
+
enabled: true,
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
const disabled = await cronService.updateJob(job.id, { enabled: false });
|
|
2112
|
+
expect(disabled.enabled).toBe(false);
|
|
2113
|
+
|
|
2114
|
+
const enabled = await cronService.updateJob(job.id, { enabled: true });
|
|
2115
|
+
expect(enabled.enabled).toBe(true);
|
|
2116
|
+
|
|
2117
|
+
const fetched = await cronService.getJob(job.id);
|
|
2118
|
+
expect(fetched?.enabled).toBe(true);
|
|
2119
|
+
});
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
describe("sse contract", () => {
|
|
2123
|
+
test("maps usage.updated to usage event", async () => {
|
|
2124
|
+
const frame = await publishAndReadSseFrame(
|
|
2125
|
+
{
|
|
2126
|
+
id: "evt-1",
|
|
2127
|
+
type: "usage.updated",
|
|
2128
|
+
source: "system",
|
|
2129
|
+
at: new Date().toISOString(),
|
|
2130
|
+
payload: repository.getUsageSnapshot(),
|
|
2131
|
+
},
|
|
2132
|
+
);
|
|
2133
|
+
expect(frame).toContain("event: usage");
|
|
2134
|
+
expect(frame).toContain("data:");
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
test("maps session.run.status.updated to session-status event", async () => {
|
|
2138
|
+
const frame = await publishAndReadSseFrame({
|
|
2139
|
+
id: "evt-2",
|
|
2140
|
+
type: "session.run.status.updated",
|
|
2141
|
+
source: "runtime",
|
|
2142
|
+
at: new Date().toISOString(),
|
|
2143
|
+
payload: {
|
|
2144
|
+
sessionId: "main",
|
|
2145
|
+
status: "retry",
|
|
2146
|
+
attempt: 1,
|
|
2147
|
+
message: "Provider overloaded",
|
|
2148
|
+
nextAt: new Date().toISOString(),
|
|
2149
|
+
},
|
|
2150
|
+
});
|
|
2151
|
+
expect(frame).toContain("event: session-status");
|
|
2152
|
+
expect(frame).toContain("data:");
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
test("maps session.message.part.updated to session-message-part event", async () => {
|
|
2156
|
+
const frame = await publishAndReadSseFrame({
|
|
2157
|
+
id: "evt-part-1",
|
|
2158
|
+
type: "session.message.part.updated",
|
|
2159
|
+
source: "runtime",
|
|
2160
|
+
at: new Date().toISOString(),
|
|
2161
|
+
payload: {
|
|
2162
|
+
sessionId: "main",
|
|
2163
|
+
messageId: "msg-1",
|
|
2164
|
+
phase: "update",
|
|
2165
|
+
observedAt: new Date().toISOString(),
|
|
2166
|
+
part: {
|
|
2167
|
+
id: "part-1",
|
|
2168
|
+
type: "tool_call",
|
|
2169
|
+
toolCallId: "call-1",
|
|
2170
|
+
tool: "search",
|
|
2171
|
+
status: "running",
|
|
2172
|
+
input: { q: "hello" },
|
|
2173
|
+
},
|
|
2174
|
+
},
|
|
2175
|
+
});
|
|
2176
|
+
expect(frame).toContain("event: session-message-part");
|
|
2177
|
+
expect(frame).toContain("data:");
|
|
2178
|
+
});
|
|
2179
|
+
|
|
2180
|
+
test("maps session.message.delta to session-message-delta event", async () => {
|
|
2181
|
+
const frame = await publishAndReadSseFrame({
|
|
2182
|
+
id: "evt-delta-1",
|
|
2183
|
+
type: "session.message.delta",
|
|
2184
|
+
source: "runtime",
|
|
2185
|
+
at: new Date().toISOString(),
|
|
2186
|
+
payload: {
|
|
2187
|
+
sessionId: "main",
|
|
2188
|
+
messageId: "msg-1",
|
|
2189
|
+
text: "Hello",
|
|
2190
|
+
mode: "append",
|
|
2191
|
+
observedAt: new Date().toISOString(),
|
|
2192
|
+
},
|
|
2193
|
+
});
|
|
2194
|
+
expect(frame).toContain("event: session-message-delta");
|
|
2195
|
+
expect(frame).toContain("data:");
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
test("maps background.run.updated to background-run event", async () => {
|
|
2199
|
+
const frame = await publishAndReadSseFrame({
|
|
2200
|
+
id: "evt-bg-1",
|
|
2201
|
+
type: "background.run.updated",
|
|
2202
|
+
source: "runtime",
|
|
2203
|
+
at: new Date().toISOString(),
|
|
2204
|
+
payload: {
|
|
2205
|
+
runId: "bg-1",
|
|
2206
|
+
parentSessionId: "main",
|
|
2207
|
+
parentExternalSessionId: "ses-1",
|
|
2208
|
+
childExternalSessionId: "ses-2",
|
|
2209
|
+
childSessionId: "session-bg-1",
|
|
2210
|
+
requestedBy: "test",
|
|
2211
|
+
prompt: "Investigate",
|
|
2212
|
+
status: "running",
|
|
2213
|
+
resultSummary: null,
|
|
2214
|
+
error: null,
|
|
2215
|
+
createdAt: new Date().toISOString(),
|
|
2216
|
+
updatedAt: new Date().toISOString(),
|
|
2217
|
+
startedAt: new Date().toISOString(),
|
|
2218
|
+
completedAt: null,
|
|
2219
|
+
},
|
|
2220
|
+
});
|
|
2221
|
+
expect(frame).toContain("event: background-run");
|
|
2222
|
+
expect(frame).toContain("data:");
|
|
2223
|
+
});
|
|
2224
|
+
|
|
2225
|
+
test("maps prompt events to prompt SSE names", async () => {
|
|
2226
|
+
const permissionRequested = await publishAndReadSseFrame({
|
|
2227
|
+
id: "evt-perm-req-1",
|
|
2228
|
+
type: "session.permission.requested",
|
|
2229
|
+
source: "runtime",
|
|
2230
|
+
at: new Date().toISOString(),
|
|
2231
|
+
payload: {
|
|
2232
|
+
id: "perm-1",
|
|
2233
|
+
sessionId: "main",
|
|
2234
|
+
permission: "Read",
|
|
2235
|
+
patterns: ["/tmp/*"],
|
|
2236
|
+
metadata: {},
|
|
2237
|
+
always: [],
|
|
2238
|
+
},
|
|
2239
|
+
});
|
|
2240
|
+
expect(permissionRequested).toContain("event: permission-requested");
|
|
2241
|
+
|
|
2242
|
+
const permissionResolved = await publishAndReadSseFrame({
|
|
2243
|
+
id: "evt-perm-res-1",
|
|
2244
|
+
type: "session.permission.resolved",
|
|
2245
|
+
source: "runtime",
|
|
2246
|
+
at: new Date().toISOString(),
|
|
2247
|
+
payload: {
|
|
2248
|
+
sessionId: "main",
|
|
2249
|
+
requestId: "perm-1",
|
|
2250
|
+
reply: "once",
|
|
2251
|
+
},
|
|
2252
|
+
});
|
|
2253
|
+
expect(permissionResolved).toContain("event: permission-resolved");
|
|
2254
|
+
|
|
2255
|
+
const questionRequested = await publishAndReadSseFrame({
|
|
2256
|
+
id: "evt-question-req-1",
|
|
2257
|
+
type: "session.question.requested",
|
|
2258
|
+
source: "runtime",
|
|
2259
|
+
at: new Date().toISOString(),
|
|
2260
|
+
payload: {
|
|
2261
|
+
id: "question-1",
|
|
2262
|
+
sessionId: "main",
|
|
2263
|
+
questions: [
|
|
2264
|
+
{
|
|
2265
|
+
question: "Pick one",
|
|
2266
|
+
header: "pick",
|
|
2267
|
+
options: [{ label: "A", description: "A option" }],
|
|
2268
|
+
},
|
|
2269
|
+
],
|
|
2270
|
+
},
|
|
2271
|
+
});
|
|
2272
|
+
expect(questionRequested).toContain("event: question-requested");
|
|
2273
|
+
|
|
2274
|
+
const questionResolved = await publishAndReadSseFrame({
|
|
2275
|
+
id: "evt-question-res-1",
|
|
2276
|
+
type: "session.question.resolved",
|
|
2277
|
+
source: "runtime",
|
|
2278
|
+
at: new Date().toISOString(),
|
|
2279
|
+
payload: {
|
|
2280
|
+
sessionId: "main",
|
|
2281
|
+
requestId: "question-1",
|
|
2282
|
+
outcome: "replied",
|
|
2283
|
+
},
|
|
2284
|
+
});
|
|
2285
|
+
expect(questionResolved).toContain("event: question-resolved");
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
test("events stream emits initial heartbeat and usage snapshots", async () => {
|
|
2289
|
+
const eventStream = createRuntimeEventStream({
|
|
2290
|
+
getHeartbeatSnapshot: repository.getHeartbeatSnapshot,
|
|
2291
|
+
getUsageSnapshot: repository.getUsageSnapshot,
|
|
2292
|
+
});
|
|
2293
|
+
const response = eventStream.route.GET();
|
|
2294
|
+
expect(response.headers.get("Content-Type")).toBe("text/event-stream");
|
|
2295
|
+
|
|
2296
|
+
const reader = response.body?.getReader() as ReadableStreamDefaultReader<StreamChunk> | undefined;
|
|
2297
|
+
expect(reader).toBeTruthy();
|
|
2298
|
+
if (!reader) return;
|
|
2299
|
+
|
|
2300
|
+
const decoder = new TextDecoder();
|
|
2301
|
+
let combined = "";
|
|
2302
|
+
for (let i = 0; i < 3; i += 1) {
|
|
2303
|
+
const chunk = await readWithTimeout(reader, 250);
|
|
2304
|
+
if (chunk.done || !chunk.value) break;
|
|
2305
|
+
combined +=
|
|
2306
|
+
typeof chunk.value === "string" ? chunk.value : decoder.decode(chunk.value);
|
|
2307
|
+
if (combined.includes("event: heartbeat") && combined.includes("event: usage")) {
|
|
2308
|
+
break;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
expect(combined).toContain("event: heartbeat");
|
|
2313
|
+
expect(combined).toContain("event: usage");
|
|
2314
|
+
await reader.cancel();
|
|
2315
|
+
});
|
|
2316
|
+
});
|