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.
Files changed (227) hide show
  1. package/.agents/skills/btca-cli/SKILL.md +64 -0
  2. package/.agents/skills/btca-cli/agents/openai.yaml +3 -0
  3. package/.agents/skills/frontend-design/SKILL.md +42 -0
  4. package/.agents/skills/frontend-design/agents/openai.yaml +3 -0
  5. package/.env.example +36 -0
  6. package/.githooks/pre-commit +33 -0
  7. package/.github/workflows/ci.yml +309 -0
  8. package/.opencode/bun.lock +18 -0
  9. package/.opencode/package.json +5 -0
  10. package/.opencode/tools/agent_type_manager.ts +100 -0
  11. package/.opencode/tools/config_manager.ts +87 -0
  12. package/.opencode/tools/cron_manager.ts +145 -0
  13. package/.opencode/tools/memory_get.ts +43 -0
  14. package/.opencode/tools/memory_remember.ts +53 -0
  15. package/.opencode/tools/memory_search.ts +48 -0
  16. package/AGENTS.md +126 -0
  17. package/MEMORY.md +2 -0
  18. package/README.md +451 -0
  19. package/THIRD_PARTY_NOTICES.md +11 -0
  20. package/agent-mockingbird.config.example.json +135 -0
  21. package/apps/server/package.json +32 -0
  22. package/apps/server/src/backend/agents/bootstrapContext.ts +362 -0
  23. package/apps/server/src/backend/agents/openclawImport.test.ts +133 -0
  24. package/apps/server/src/backend/agents/openclawImport.ts +797 -0
  25. package/apps/server/src/backend/agents/opencodeConfig.ts +428 -0
  26. package/apps/server/src/backend/agents/service.ts +10 -0
  27. package/apps/server/src/backend/config/example-config.test.ts +20 -0
  28. package/apps/server/src/backend/config/orchestration.ts +243 -0
  29. package/apps/server/src/backend/config/policy.ts +158 -0
  30. package/apps/server/src/backend/config/schema.test.ts +15 -0
  31. package/apps/server/src/backend/config/schema.ts +391 -0
  32. package/apps/server/src/backend/config/semantic.test.ts +34 -0
  33. package/apps/server/src/backend/config/semantic.ts +149 -0
  34. package/apps/server/src/backend/config/service.test.ts +75 -0
  35. package/apps/server/src/backend/config/service.ts +207 -0
  36. package/apps/server/src/backend/config/smoke.ts +77 -0
  37. package/apps/server/src/backend/config/store.test.ts +123 -0
  38. package/apps/server/src/backend/config/store.ts +581 -0
  39. package/apps/server/src/backend/config/testFixtures.ts +5 -0
  40. package/apps/server/src/backend/config/types.ts +56 -0
  41. package/apps/server/src/backend/contracts/events.ts +320 -0
  42. package/apps/server/src/backend/contracts/runtime.ts +111 -0
  43. package/apps/server/src/backend/cron/executor.ts +435 -0
  44. package/apps/server/src/backend/cron/repository.ts +170 -0
  45. package/apps/server/src/backend/cron/service.ts +660 -0
  46. package/apps/server/src/backend/cron/storage.ts +92 -0
  47. package/apps/server/src/backend/cron/types.ts +138 -0
  48. package/apps/server/src/backend/cron/utils.ts +351 -0
  49. package/apps/server/src/backend/db/client.ts +20 -0
  50. package/apps/server/src/backend/db/migrate.ts +40 -0
  51. package/apps/server/src/backend/db/repository.ts +1762 -0
  52. package/apps/server/src/backend/db/schema.ts +113 -0
  53. package/apps/server/src/backend/db/usageDashboard.test.ts +102 -0
  54. package/apps/server/src/backend/db/wipe.ts +13 -0
  55. package/apps/server/src/backend/defaults.ts +32 -0
  56. package/apps/server/src/backend/env.ts +48 -0
  57. package/apps/server/src/backend/heartbeat/activeHours.ts +45 -0
  58. package/apps/server/src/backend/heartbeat/defaultJob.ts +88 -0
  59. package/apps/server/src/backend/heartbeat/heartbeat.test.ts +110 -0
  60. package/apps/server/src/backend/heartbeat/runtimeService.ts +190 -0
  61. package/apps/server/src/backend/heartbeat/service.ts +176 -0
  62. package/apps/server/src/backend/heartbeat/state.test.ts +63 -0
  63. package/apps/server/src/backend/heartbeat/state.ts +167 -0
  64. package/apps/server/src/backend/heartbeat/types.ts +54 -0
  65. package/apps/server/src/backend/http/boundedQueue.test.ts +49 -0
  66. package/apps/server/src/backend/http/boundedQueue.ts +92 -0
  67. package/apps/server/src/backend/http/parsers.ts +40 -0
  68. package/apps/server/src/backend/http/router.ts +61 -0
  69. package/apps/server/src/backend/http/routes/agentRoutes.ts +67 -0
  70. package/apps/server/src/backend/http/routes/backgroundRoutes.ts +203 -0
  71. package/apps/server/src/backend/http/routes/chatRoutes.ts +107 -0
  72. package/apps/server/src/backend/http/routes/configRoutes.ts +602 -0
  73. package/apps/server/src/backend/http/routes/cronRoutes.ts +221 -0
  74. package/apps/server/src/backend/http/routes/dashboardRoutes.ts +308 -0
  75. package/apps/server/src/backend/http/routes/eventRoutes.ts +7 -0
  76. package/apps/server/src/backend/http/routes/heartbeatRoutes.test.ts +41 -0
  77. package/apps/server/src/backend/http/routes/heartbeatRoutes.ts +28 -0
  78. package/apps/server/src/backend/http/routes/index.ts +101 -0
  79. package/apps/server/src/backend/http/routes/mcpRoutes.ts +213 -0
  80. package/apps/server/src/backend/http/routes/memoryRoutes.ts +154 -0
  81. package/apps/server/src/backend/http/routes/runRoutes.ts +310 -0
  82. package/apps/server/src/backend/http/routes/runtimeRoutes.ts +197 -0
  83. package/apps/server/src/backend/http/routes/skillRoutes.ts +112 -0
  84. package/apps/server/src/backend/http/routes/uiRoutes.test.ts +161 -0
  85. package/apps/server/src/backend/http/routes/uiRoutes.ts +177 -0
  86. package/apps/server/src/backend/http/routes/usageRoutes.test.ts +104 -0
  87. package/apps/server/src/backend/http/routes/usageRoutes.ts +767 -0
  88. package/apps/server/src/backend/http/schemas.ts +64 -0
  89. package/apps/server/src/backend/http/sse.ts +144 -0
  90. package/apps/server/src/backend/integration/backend-core.test.ts +2316 -0
  91. package/apps/server/src/backend/logging/logger.ts +64 -0
  92. package/apps/server/src/backend/mcp/service.ts +326 -0
  93. package/apps/server/src/backend/memory/cli.ts +170 -0
  94. package/apps/server/src/backend/memory/conceptExpansion.test.ts +28 -0
  95. package/apps/server/src/backend/memory/conceptExpansion.ts +80 -0
  96. package/apps/server/src/backend/memory/qmdPort.test.ts +54 -0
  97. package/apps/server/src/backend/memory/qmdPort.ts +61 -0
  98. package/apps/server/src/backend/memory/records.test.ts +66 -0
  99. package/apps/server/src/backend/memory/records.ts +229 -0
  100. package/apps/server/src/backend/memory/service.ts +2012 -0
  101. package/apps/server/src/backend/memory/sqliteVec.ts +58 -0
  102. package/apps/server/src/backend/memory/types.ts +104 -0
  103. package/apps/server/src/backend/opencode/agentMockingbirdPlugin.test.ts +396 -0
  104. package/apps/server/src/backend/opencode/client.ts +98 -0
  105. package/apps/server/src/backend/opencode/models.ts +41 -0
  106. package/apps/server/src/backend/opencode/systemPrompt.test.ts +146 -0
  107. package/apps/server/src/backend/opencode/systemPrompt.ts +284 -0
  108. package/apps/server/src/backend/paths.ts +57 -0
  109. package/apps/server/src/backend/prompts/service.ts +100 -0
  110. package/apps/server/src/backend/queue/queue.test.ts +189 -0
  111. package/apps/server/src/backend/queue/service.ts +177 -0
  112. package/apps/server/src/backend/queue/types.ts +39 -0
  113. package/apps/server/src/backend/run/service.ts +576 -0
  114. package/apps/server/src/backend/run/storage.ts +47 -0
  115. package/apps/server/src/backend/run/types.ts +44 -0
  116. package/apps/server/src/backend/runtime/errors.ts +61 -0
  117. package/apps/server/src/backend/runtime/index.ts +72 -0
  118. package/apps/server/src/backend/runtime/memoryPromptDedup.test.ts +153 -0
  119. package/apps/server/src/backend/runtime/memoryPromptDedup.ts +76 -0
  120. package/apps/server/src/backend/runtime/opencodeRuntime/backgroundMethods.ts +765 -0
  121. package/apps/server/src/backend/runtime/opencodeRuntime/coreMethods.ts +705 -0
  122. package/apps/server/src/backend/runtime/opencodeRuntime/eventMethods.ts +503 -0
  123. package/apps/server/src/backend/runtime/opencodeRuntime/memoryMethods.ts +462 -0
  124. package/apps/server/src/backend/runtime/opencodeRuntime/promptMethods.ts +1167 -0
  125. package/apps/server/src/backend/runtime/opencodeRuntime/shared.ts +254 -0
  126. package/apps/server/src/backend/runtime/opencodeRuntime.test.ts +2899 -0
  127. package/apps/server/src/backend/runtime/opencodeRuntime.ts +135 -0
  128. package/apps/server/src/backend/runtime/sessionScope.ts +45 -0
  129. package/apps/server/src/backend/skills/service.ts +442 -0
  130. package/apps/server/src/backend/workspace/resolve.ts +27 -0
  131. package/apps/server/src/cli/agent-mockingbird.mjs +2522 -0
  132. package/apps/server/src/cli/agent-mockingbird.test.ts +68 -0
  133. package/apps/server/src/cli/runtime-assets.mjs +269 -0
  134. package/apps/server/src/cli/runtime-assets.test.ts +52 -0
  135. package/apps/server/src/cli/runtime-layout.mjs +75 -0
  136. package/apps/server/src/cli/standaloneBuild.test.ts +19 -0
  137. package/apps/server/src/cli/standaloneBuild.ts +19 -0
  138. package/apps/server/src/cli/standaloneCronBinary.test.ts +187 -0
  139. package/apps/server/src/index.ts +178 -0
  140. package/apps/server/tsconfig.json +12 -0
  141. package/backlog.md +5 -0
  142. package/bin/agent-mockingbird +2522 -0
  143. package/bin/runtime-layout.mjs +75 -0
  144. package/build-bin.ts +34 -0
  145. package/build-cli.mjs +37 -0
  146. package/build.ts +40 -0
  147. package/bun-env.d.ts +11 -0
  148. package/bun.lock +888 -0
  149. package/bunfig.toml +2 -0
  150. package/components.json +21 -0
  151. package/config.json +130 -0
  152. package/deploy/RELEASE_INSTALL.md +112 -0
  153. package/deploy/docker-compose.yml +42 -0
  154. package/deploy/systemd/README.md +46 -0
  155. package/deploy/systemd/agent-mockingbird.service +28 -0
  156. package/deploy/systemd/opencode.service +25 -0
  157. package/docs/legacy-config-ui-reference.md +51 -0
  158. package/docs/memory-e2e-trace-2026-03-04.md +63 -0
  159. package/docs/memory-ops.md +96 -0
  160. package/docs/memory-runtime-contract.md +42 -0
  161. package/docs/memory-tuning-remote-2026-03-04.md +59 -0
  162. package/docs/opencode-rebase-workflow-plan.md +614 -0
  163. package/docs/opencode-startup-sync-plan.md +94 -0
  164. package/docs/vendor-opencode.md +41 -0
  165. package/drizzle/0000_famous_turbo.sql +49 -0
  166. package/drizzle/0001_cron_memory_aux.sql +160 -0
  167. package/drizzle/0002_runtime_session_bindings.sql +28 -0
  168. package/drizzle/0003_background_runs.sql +27 -0
  169. package/drizzle/0004_memory_open_write.sql +63 -0
  170. package/drizzle/0005_signal_channel.sql +47 -0
  171. package/drizzle/0006_usage_event_dimensions.sql +7 -0
  172. package/drizzle/meta/0000_snapshot.json +341 -0
  173. package/drizzle/meta/_journal.json +55 -0
  174. package/drizzle.config.ts +14 -0
  175. package/eslint.config.mjs +77 -0
  176. package/knip.json +18 -0
  177. package/memory/2026-03-04.md +4 -0
  178. package/opencode.lock.json +16 -0
  179. package/package.json +67 -0
  180. package/packages/agent-mockingbird-installer/README.md +31 -0
  181. package/packages/agent-mockingbird-installer/bin/agent-mockingbird-installer.mjs +44 -0
  182. package/packages/agent-mockingbird-installer/opencode.lock.json +16 -0
  183. package/packages/agent-mockingbird-installer/package.json +23 -0
  184. package/packages/contracts/package.json +19 -0
  185. package/packages/contracts/src/agentTypes.ts +122 -0
  186. package/packages/contracts/src/cron.ts +146 -0
  187. package/packages/contracts/src/dashboard.ts +378 -0
  188. package/packages/contracts/src/index.ts +3 -0
  189. package/packages/contracts/tsconfig.json +4 -0
  190. package/patches/opencode/0001-Wafflebot-OpenCode-baseline.patch +2341 -0
  191. package/patches/opencode/0002-Fix-OpenCode-web-entry-and-settings-icons.patch +104 -0
  192. package/patches/opencode/0003-fix-app-remove-duplicate-sidebar-mount.patch +32 -0
  193. package/patches/opencode/0004-Add-heartbeat-settings-and-usage-nav.patch +506 -0
  194. package/patches/opencode/0005-Use-chart-icon-for-usage-nav.patch +38 -0
  195. package/patches/opencode/0006-Modernize-cron-settings.patch +399 -0
  196. package/patches/opencode/0007-Rename-waffle-namespaces-to-mockingbird.patch +1110 -0
  197. package/patches/opencode/0008-Remove-cron-contract-section.patch +178 -0
  198. package/patches/opencode/0009-Rework-cron-tab-as-operations-console.patch +414 -0
  199. package/patches/opencode/0010-Refine-heartbeat-settings-controls.patch +208 -0
  200. package/runtime-assets/opencode-config/opencode.jsonc +25 -0
  201. package/runtime-assets/opencode-config/package.json +5 -0
  202. package/runtime-assets/opencode-config/plugins/agent-mockingbird.ts +715 -0
  203. package/runtime-assets/workspace/.agents/skills/config-auditor/SKILL.md +25 -0
  204. package/runtime-assets/workspace/.agents/skills/config-editor/SKILL.md +24 -0
  205. package/runtime-assets/workspace/.agents/skills/cron-manager/SKILL.md +57 -0
  206. package/runtime-assets/workspace/.agents/skills/memory-ops/SKILL.md +120 -0
  207. package/runtime-assets/workspace/.agents/skills/runtime-diagnose/SKILL.md +25 -0
  208. package/runtime-assets/workspace/AGENTS.md +56 -0
  209. package/runtime-assets/workspace/MEMORY.md +4 -0
  210. package/scripts/build-release-bundle.sh +66 -0
  211. package/scripts/check-ship.ts +383 -0
  212. package/scripts/dev-opencode.sh +17 -0
  213. package/scripts/dev-stack-opencode.sh +15 -0
  214. package/scripts/dev-stack.sh +61 -0
  215. package/scripts/install-systemd.sh +87 -0
  216. package/scripts/memory-e2e.sh +76 -0
  217. package/scripts/memory-trace-e2e.sh +141 -0
  218. package/scripts/migrate-opencode-env.ts +108 -0
  219. package/scripts/onboard/bootstrap.sh +32 -0
  220. package/scripts/opencode-swap.ts +78 -0
  221. package/scripts/opencode-sync.ts +715 -0
  222. package/scripts/runtime-assets-sync.mjs +83 -0
  223. package/scripts/setup-git-hooks.ts +39 -0
  224. package/tsconfig.json +45 -0
  225. package/tui.json +98 -0
  226. package/turbo.json +36 -0
  227. package/vendor/OPENCODE_VENDOR.md +13 -0
@@ -0,0 +1,2899 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+
6
+ import type { MemorySearchResult } from "../memory/types";
7
+
8
+ const testRoot = mkdtempSync(path.join(tmpdir(), "agent-mockingbird-runtime-test-"));
9
+ const testDbPath = path.join(testRoot, "agent-mockingbird.runtime.test.db");
10
+ const testWorkspacePath = path.join(testRoot, "workspace");
11
+ const testConfigPath = path.join(testRoot, "agent-mockingbird.runtime.config.json");
12
+
13
+ process.env.NODE_ENV = "test";
14
+ process.env.AGENT_MOCKINGBIRD_DB_PATH = testDbPath;
15
+ process.env.AGENT_MOCKINGBIRD_CONFIG_PATH = testConfigPath;
16
+ process.env.AGENT_MOCKINGBIRD_MEMORY_WORKSPACE_DIR = testWorkspacePath;
17
+ process.env.AGENT_MOCKINGBIRD_MEMORY_ENABLED = "false";
18
+ process.env.AGENT_MOCKINGBIRD_MEMORY_TOOL_MODE = "tool_only";
19
+ process.env.AGENT_MOCKINGBIRD_CRON_ENABLED = "false";
20
+
21
+ interface RepositoryApi {
22
+ ensureSeedData: () => void;
23
+ resetDatabaseToDefaults: () => unknown;
24
+ createSession: (input?: { title?: string; model?: string }) => { id: string; title: string; model: string };
25
+ getSessionById: (sessionId: string) => { id: string; title: string; model: string } | null;
26
+ setSessionModel: (sessionId: string, model: string) => { id: string; model: string } | null;
27
+ listMessagesForSession: (sessionId: string) => Array<{ id: string; role: string; content: string; at: string }>;
28
+ upsertSessionMessages: (input: {
29
+ sessionId: string;
30
+ messages: Array<{
31
+ id: string;
32
+ role: "user" | "assistant";
33
+ content: string;
34
+ createdAt: number;
35
+ }>;
36
+ }) => unknown;
37
+ appendChatExchange: (input: {
38
+ sessionId: string;
39
+ userContent: string;
40
+ assistantContent: string;
41
+ source: "api" | "runtime" | "scheduler" | "system";
42
+ createdAt?: number;
43
+ userMessageId?: string;
44
+ assistantMessageId?: string;
45
+ usage: {
46
+ requestCountDelta: number;
47
+ inputTokensDelta: number;
48
+ outputTokensDelta: number;
49
+ estimatedCostUsdDelta: number;
50
+ };
51
+ }) =>
52
+ | {
53
+ messages: Array<{ id: string; role: "user" | "assistant"; content: string; at: string }>;
54
+ }
55
+ | null;
56
+ appendAssistantMessage: (input: {
57
+ sessionId: string;
58
+ content: string;
59
+ source: "api" | "runtime" | "scheduler" | "system";
60
+ createdAt?: number;
61
+ messageId?: string;
62
+ }) => { message: { id: string; role: "assistant"; content: string } } | null;
63
+ }
64
+
65
+ type PromptInput = {
66
+ path: { id: string };
67
+ body?: {
68
+ model?: {
69
+ providerID?: string;
70
+ modelID?: string;
71
+ };
72
+ system?: string;
73
+ agent?: string;
74
+ parts?: Array<{ type: string; text?: string }>;
75
+ };
76
+ signal?: AbortSignal;
77
+ };
78
+
79
+ type PromptAsyncInput = {
80
+ path: { id: string };
81
+ body?: {
82
+ model?: {
83
+ providerID?: string;
84
+ modelID?: string;
85
+ };
86
+ parts?: Array<{ type: string; text?: string }>;
87
+ };
88
+ signal?: AbortSignal;
89
+ };
90
+
91
+ interface MockClient {
92
+ session: {
93
+ create: (input: unknown) => Promise<unknown>;
94
+ children: (input: unknown) => Promise<unknown>;
95
+ prompt: (input: PromptInput) => Promise<unknown>;
96
+ promptAsync: (input: PromptAsyncInput) => Promise<unknown>;
97
+ status: (input: unknown) => Promise<unknown>;
98
+ messages: (input: unknown) => Promise<unknown>;
99
+ message: (input: unknown) => Promise<unknown>;
100
+ get: (input: unknown) => Promise<unknown>;
101
+ abort: (input: unknown) => Promise<unknown>;
102
+ summarize: (input: unknown) => Promise<unknown>;
103
+ };
104
+ event: {
105
+ subscribe: (input: unknown) => Promise<{ stream: AsyncIterable<unknown> }>;
106
+ };
107
+ config: {
108
+ get: (input: unknown) => Promise<unknown>;
109
+ update: (input: unknown) => Promise<unknown>;
110
+ };
111
+ app: {
112
+ agents: (input?: unknown) => Promise<unknown>;
113
+ };
114
+ }
115
+
116
+ type ConfiguredMcpServer =
117
+ | {
118
+ id: string;
119
+ type: "remote";
120
+ enabled: boolean;
121
+ url: string;
122
+ headers: Record<string, string>;
123
+ oauth: "auto" | "off";
124
+ timeoutMs?: number;
125
+ }
126
+ | {
127
+ id: string;
128
+ type: "local";
129
+ enabled: boolean;
130
+ command: string[];
131
+ environment: Record<string, string>;
132
+ timeoutMs?: number;
133
+ };
134
+
135
+ type RuntimeCtor = new (input: {
136
+ defaultProviderId: string;
137
+ defaultModelId: string;
138
+ fallbackModelRefs?: Array<string>;
139
+ client?: unknown;
140
+ getRuntimeConfig?: () => {
141
+ baseUrl: string;
142
+ providerId: string;
143
+ modelId: string;
144
+ fallbackModels: string[];
145
+ imageModel: string | null;
146
+ smallModel: string;
147
+ timeoutMs: number;
148
+ promptTimeoutMs: number;
149
+ runWaitTimeoutMs: number;
150
+ childSessionHideAfterDays: number;
151
+ directory: string | null;
152
+ bootstrap: {
153
+ enabled: boolean;
154
+ maxCharsPerFile: number;
155
+ maxCharsTotal: number;
156
+ subagentMinimal: boolean;
157
+ includeAgentPrompt: boolean;
158
+ };
159
+ };
160
+ getEnabledSkills?: () => Array<string>;
161
+ getEnabledMcps?: () => Array<string>;
162
+ getConfiguredMcpServers?: () => Array<ConfiguredMcpServer>;
163
+ searchMemoryFn?: (query: string, options?: { maxResults?: number; minScore?: number }) => Promise<MemorySearchResult[]>;
164
+ enableEventSync?: boolean;
165
+ enableSmallModelSync?: boolean;
166
+ enableBackgroundSync?: boolean;
167
+ }) => {
168
+ subscribe: (listener: (event: unknown) => void) => () => void;
169
+ checkHealth: (input?: { force?: boolean }) => Promise<{
170
+ ok: boolean;
171
+ fromCache: boolean;
172
+ error: { name: string; message: string } | null;
173
+ responseText: string | null;
174
+ latencyMs: number | null;
175
+ }>;
176
+ syncSessionMessages: (sessionId: string) => Promise<void>;
177
+ sendUserMessage: (input: {
178
+ sessionId: string;
179
+ content: string;
180
+ agent?: string;
181
+ metadata?: Record<string, unknown>;
182
+ }) => Promise<{
183
+ sessionId: string;
184
+ messages: Array<{ id: string; role: "user" | "assistant"; content: string }>;
185
+ }>;
186
+ spawnBackgroundSession: (input: {
187
+ parentSessionId: string;
188
+ title?: string;
189
+ requestedBy?: string;
190
+ prompt?: string;
191
+ }) => Promise<{
192
+ runId: string;
193
+ parentSessionId: string;
194
+ parentExternalSessionId: string;
195
+ childExternalSessionId: string;
196
+ childSessionId: string | null;
197
+ status: string;
198
+ startedAt: string | null;
199
+ completedAt: string | null;
200
+ error: string | null;
201
+ }>;
202
+ promptBackgroundAsync: (input: {
203
+ runId: string;
204
+ content: string;
205
+ model?: string;
206
+ system?: string;
207
+ agent?: string;
208
+ noReply?: boolean;
209
+ }) => Promise<{
210
+ runId: string;
211
+ status: string;
212
+ }>;
213
+ getBackgroundStatus: (runId: string) => Promise<{
214
+ runId: string;
215
+ status: string;
216
+ completedAt: string | null;
217
+ } | null>;
218
+ listBackgroundRuns: (input?: {
219
+ parentSessionId?: string;
220
+ limit?: number;
221
+ inFlightOnly?: boolean;
222
+ }) => Promise<
223
+ Array<{
224
+ runId: string;
225
+ childExternalSessionId: string;
226
+ childSessionId: string | null;
227
+ status: string;
228
+ completedAt: string | null;
229
+ }>
230
+ >;
231
+ abortBackground: (runId: string) => Promise<boolean>;
232
+ };
233
+
234
+ let repository: RepositoryApi;
235
+ let OpencodeRuntime: RuntimeCtor;
236
+ let RuntimeProviderQuotaError: new (message?: string) => Error;
237
+ let RuntimeProviderAuthError: new (message?: string) => Error;
238
+ let RuntimeProviderRateLimitError: new (message?: string) => Error;
239
+ let RuntimeSessionBusyError: new (sessionId: string) => Error;
240
+ let RuntimeSessionQueuedError: new (sessionId: string, depth: number) => Error;
241
+ let RuntimeContinuationDetachedError: new (sessionId: string, childRunCount: number) => Error;
242
+
243
+ beforeAll(async () => {
244
+ await import("../db/migrate");
245
+ const configService = (await import("../config/service")) as unknown as {
246
+ getConfigSnapshot: () => unknown;
247
+ };
248
+ configService.getConfigSnapshot();
249
+ repository = (await import("../db/repository")) as unknown as RepositoryApi;
250
+ ({ OpencodeRuntime } = (await import("./opencodeRuntime")) as unknown as {
251
+ OpencodeRuntime: RuntimeCtor;
252
+ });
253
+ ({
254
+ RuntimeProviderQuotaError,
255
+ RuntimeProviderAuthError,
256
+ RuntimeProviderRateLimitError,
257
+ RuntimeSessionBusyError,
258
+ RuntimeSessionQueuedError,
259
+ RuntimeContinuationDetachedError,
260
+ } = (await import("./errors")) as unknown as {
261
+ RuntimeProviderQuotaError: new (message?: string) => Error;
262
+ RuntimeProviderAuthError: new (message?: string) => Error;
263
+ RuntimeProviderRateLimitError: new (message?: string) => Error;
264
+ RuntimeSessionBusyError: new (sessionId: string) => Error;
265
+ RuntimeSessionQueuedError: new (sessionId: string, depth: number) => Error;
266
+ RuntimeContinuationDetachedError: new (sessionId: string, childRunCount: number) => Error;
267
+ });
268
+ repository.ensureSeedData();
269
+ });
270
+
271
+ beforeEach(async () => {
272
+ repository.resetDatabaseToDefaults();
273
+ repository.setSessionModel("main", "test-provider/test-model");
274
+ const { getLaneQueue } = (await import("../queue/service")) as unknown as {
275
+ getLaneQueue: () => { clearAll: () => void };
276
+ };
277
+ getLaneQueue().clearAll();
278
+ });
279
+
280
+ afterAll(() => {
281
+ rmSync(testRoot, { recursive: true, force: true });
282
+ });
283
+
284
+ function assistantResponse(sessionID: string, text: string) {
285
+ const now = Date.now();
286
+ const parentID = `msg-user-${crypto.randomUUID().slice(0, 8)}`;
287
+ return {
288
+ data: {
289
+ info: {
290
+ id: `msg-${crypto.randomUUID().slice(0, 8)}`,
291
+ sessionID,
292
+ parentID,
293
+ role: "assistant",
294
+ summary: false,
295
+ mode: "build",
296
+ finish: "stop",
297
+ time: {
298
+ created: now,
299
+ completed: now,
300
+ },
301
+ tokens: {
302
+ input: 12,
303
+ output: 24,
304
+ },
305
+ cost: 0,
306
+ },
307
+ parts: [
308
+ {
309
+ type: "text",
310
+ text,
311
+ },
312
+ ],
313
+ },
314
+ };
315
+ }
316
+
317
+ function assistantResponseWithId(sessionID: string, id: string, text: string) {
318
+ const now = Date.now();
319
+ const parentID = `msg-user-${crypto.randomUUID().slice(0, 8)}`;
320
+ return {
321
+ data: {
322
+ info: {
323
+ id,
324
+ sessionID,
325
+ parentID,
326
+ role: "assistant",
327
+ summary: false,
328
+ mode: "build",
329
+ finish: "stop",
330
+ time: {
331
+ created: now,
332
+ completed: now,
333
+ },
334
+ tokens: {
335
+ input: 12,
336
+ output: 24,
337
+ },
338
+ cost: 0,
339
+ },
340
+ parts: [
341
+ {
342
+ type: "text",
343
+ text,
344
+ },
345
+ ],
346
+ },
347
+ };
348
+ }
349
+
350
+ function assistantResponseWithIds(sessionID: string, input: { id: string; parentID: string; text: string }) {
351
+ const now = Date.now();
352
+ return {
353
+ data: {
354
+ info: {
355
+ id: input.id,
356
+ parentID: input.parentID,
357
+ sessionID,
358
+ role: "assistant",
359
+ summary: false,
360
+ mode: "build",
361
+ finish: "stop",
362
+ time: {
363
+ created: now,
364
+ completed: now,
365
+ },
366
+ tokens: {
367
+ input: 12,
368
+ output: 24,
369
+ },
370
+ cost: 0,
371
+ },
372
+ parts: [
373
+ {
374
+ type: "text",
375
+ text: input.text,
376
+ },
377
+ ],
378
+ },
379
+ };
380
+ }
381
+
382
+ function assistantReasoningOnlyResponse(sessionID: string, text: string) {
383
+ const now = Date.now();
384
+ return {
385
+ data: {
386
+ info: {
387
+ id: `msg-${crypto.randomUUID().slice(0, 8)}`,
388
+ sessionID,
389
+ role: "assistant",
390
+ summary: false,
391
+ mode: "build",
392
+ finish: "stop",
393
+ time: {
394
+ created: now,
395
+ completed: now,
396
+ },
397
+ tokens: {
398
+ input: 10,
399
+ output: 20,
400
+ },
401
+ cost: 0,
402
+ },
403
+ parts: [
404
+ {
405
+ type: "reasoning",
406
+ text,
407
+ time: { start: now, end: now },
408
+ },
409
+ ],
410
+ },
411
+ };
412
+ }
413
+
414
+ function createMockClient(input: {
415
+ prompt: (request: PromptInput) => Promise<unknown>;
416
+ create?: (request: unknown) => Promise<unknown>;
417
+ promptAsync?: (request: PromptAsyncInput) => Promise<unknown>;
418
+ status?: () => Promise<unknown>;
419
+ get?: (request: unknown) => Promise<unknown>;
420
+ messages?: (request: unknown) => Promise<unknown>;
421
+ message?: (request: unknown) => Promise<unknown>;
422
+ children?: (request: unknown) => Promise<unknown>;
423
+ appAgents?: (request?: unknown) => Promise<unknown>;
424
+ }): MockClient {
425
+ let createCount = 0;
426
+ return {
427
+ session: {
428
+ create: async (request) => {
429
+ if (input.create) return input.create(request);
430
+ createCount += 1;
431
+ return {
432
+ data: {
433
+ id: `ses-${createCount}`,
434
+ title: "main",
435
+ },
436
+ };
437
+ },
438
+ children: async (request) => {
439
+ if (input.children) return input.children(request);
440
+ return { data: [] };
441
+ },
442
+ prompt: input.prompt,
443
+ promptAsync: async (request) => {
444
+ if (input.promptAsync) return input.promptAsync(request);
445
+ return { data: undefined };
446
+ },
447
+ status: async () => {
448
+ if (input.status) return input.status();
449
+ return {
450
+ data: {},
451
+ };
452
+ },
453
+ messages: async (request) => {
454
+ if (input.messages) return input.messages(request);
455
+ return {
456
+ data: [assistantResponse((request as { path: { id: string } }).path.id, "Background result").data],
457
+ };
458
+ },
459
+ message: async (request) => {
460
+ if (input.message) return input.message(request);
461
+ const path = (request as { path: { id: string; messageID: string } }).path;
462
+ return {
463
+ data: assistantResponse(path.id, "Background result").data,
464
+ };
465
+ },
466
+ get: async (request) => {
467
+ if (input.get) return input.get(request);
468
+ return {
469
+ data: {
470
+ id: (request as { path: { id: string } }).path.id,
471
+ title: "main",
472
+ },
473
+ };
474
+ },
475
+ abort: async () => ({ data: true }),
476
+ summarize: async () => ({ data: true }),
477
+ },
478
+ event: {
479
+ subscribe: async () => ({
480
+ stream: (async function* () {})(),
481
+ }),
482
+ },
483
+ config: {
484
+ get: async () => ({
485
+ data: { small_model: "test-provider/test-small" },
486
+ }),
487
+ update: async () => ({ data: {} }),
488
+ },
489
+ app: {
490
+ agents: async (request) => {
491
+ if (input.appAgents) return input.appAgents(request);
492
+ return {
493
+ data: [
494
+ { name: "agent-mockingbird", mode: "primary" },
495
+ { name: "general", mode: "subagent" },
496
+ ],
497
+ };
498
+ },
499
+ },
500
+ };
501
+ }
502
+
503
+ function createRuntimeWithClient(
504
+ client: MockClient,
505
+ options?: {
506
+ fallbackModelRefs?: Array<string>;
507
+ getEnabledSkills?: () => Array<string>;
508
+ getEnabledMcps?: () => Array<string>;
509
+ getConfiguredMcpServers?: () => Array<ConfiguredMcpServer>;
510
+ searchMemoryFn?: (query: string, options?: { maxResults?: number; minScore?: number }) => Promise<MemorySearchResult[]>;
511
+ enableSmallModelSync?: boolean;
512
+ enableBackgroundSync?: boolean;
513
+ runtimeDirectory?: string | null;
514
+ },
515
+ ) {
516
+ return new OpencodeRuntime({
517
+ defaultProviderId: "test-provider",
518
+ defaultModelId: "test-model",
519
+ fallbackModelRefs: options?.fallbackModelRefs,
520
+ getRuntimeConfig: () => ({
521
+ baseUrl: "http://127.0.0.1:4096",
522
+ providerId: "test-provider",
523
+ modelId: "test-model",
524
+ fallbackModels: options?.fallbackModelRefs ?? [],
525
+ imageModel: null,
526
+ smallModel: "test-provider/test-small",
527
+ timeoutMs: 120_000,
528
+ promptTimeoutMs: 20,
529
+ runWaitTimeoutMs: 180_000,
530
+ childSessionHideAfterDays: 3,
531
+ directory: options?.runtimeDirectory ?? null,
532
+ bootstrap: {
533
+ enabled: true,
534
+ maxCharsPerFile: 20_000,
535
+ maxCharsTotal: 150_000,
536
+ subagentMinimal: true,
537
+ includeAgentPrompt: true,
538
+ },
539
+ }),
540
+ getEnabledSkills: options?.getEnabledSkills,
541
+ getEnabledMcps: options?.getEnabledMcps,
542
+ getConfiguredMcpServers: options?.getConfiguredMcpServers,
543
+ searchMemoryFn: options?.searchMemoryFn,
544
+ client: client as unknown,
545
+ enableEventSync: false,
546
+ enableSmallModelSync: options?.enableSmallModelSync ?? false,
547
+ enableBackgroundSync: options?.enableBackgroundSync ?? false,
548
+ });
549
+ }
550
+
551
+ async function sleep(ms: number) {
552
+ await new Promise((resolve) => setTimeout(resolve, ms));
553
+ }
554
+
555
+ function updateRuntimeMemoryConfig(
556
+ patch: Partial<{
557
+ enabled: boolean;
558
+ workspaceDir: string;
559
+ embedProvider: "ollama" | "none";
560
+ toolMode: "hybrid" | "inject_only" | "tool_only";
561
+ minScore: number;
562
+ injectionDedupeEnabled: boolean;
563
+ injectionDedupeFallbackRecallOnly: boolean;
564
+ injectionDedupeMaxTracked: number;
565
+ }>,
566
+ ) {
567
+ if (!existsSync(testConfigPath)) {
568
+ throw new Error(`Missing runtime test config: ${testConfigPath}`);
569
+ }
570
+ const raw = JSON.parse(readFileSync(testConfigPath, "utf8")) as {
571
+ runtime?: {
572
+ memory?: Record<string, unknown>;
573
+ };
574
+ };
575
+ const runtime = raw.runtime ?? {};
576
+ const memory = runtime.memory ?? {};
577
+ runtime.memory = {
578
+ ...memory,
579
+ ...patch,
580
+ };
581
+ raw.runtime = runtime;
582
+ writeFileSync(testConfigPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
583
+ }
584
+
585
+ async function seedMemoryFixture(marker: string) {
586
+ mkdirSync(testWorkspacePath, { recursive: true });
587
+ writeFileSync(
588
+ path.join(testWorkspacePath, "MEMORY.md"),
589
+ `# Durable Memory\n\nMarker ${marker} is important for memory injection tests.\n`,
590
+ "utf8",
591
+ );
592
+ const memoryService = (await import("../memory/service")) as unknown as {
593
+ syncMemoryIndex: (input?: { force?: boolean }) => Promise<void>;
594
+ searchMemory: (query: string, options?: { maxResults?: number; minScore?: number }) => Promise<Array<unknown>>;
595
+ };
596
+ await memoryService.syncMemoryIndex({ force: true });
597
+ const warmup = await memoryService.searchMemory("Durable Memory", { minScore: 0, maxResults: 6 });
598
+ expect(warmup.length).toBeGreaterThan(0);
599
+ }
600
+
601
+ describe("opencode runtime failover contract", () => {
602
+ test("recreates session and retries prompt when provider returns 404", async () => {
603
+ const sessionIds: string[] = [];
604
+ let createCount = 0;
605
+ let promptCount = 0;
606
+ const runtime = createRuntimeWithClient(
607
+ createMockClient({
608
+ create: async () => {
609
+ createCount += 1;
610
+ return {
611
+ data: {
612
+ id: `ses-${createCount}`,
613
+ title: "main",
614
+ },
615
+ };
616
+ },
617
+ prompt: async (request) => {
618
+ sessionIds.push(request.path.id);
619
+ promptCount += 1;
620
+ if (promptCount === 1) {
621
+ throw Object.assign(new Error("session missing"), { status: 404 });
622
+ }
623
+ return assistantResponse(request.path.id, "Recovered reply");
624
+ },
625
+ }),
626
+ );
627
+
628
+ const ack = await runtime.sendUserMessage({
629
+ sessionId: "main",
630
+ content: "hello",
631
+ });
632
+
633
+ expect(ack.sessionId).toBe("main");
634
+ expect(ack.messages.at(-1)?.role).toBe("assistant");
635
+ expect(ack.messages.at(-1)?.content).toBe("Recovered reply");
636
+ expect(createCount).toBe(2);
637
+ expect(sessionIds.length).toBe(2);
638
+ expect(sessionIds[0]).not.toBe(sessionIds[1]);
639
+ });
640
+
641
+ test("drops unavailable requested agent before prompt dispatch", async () => {
642
+ const seenAgents: Array<string | undefined> = [];
643
+ const events: Array<unknown> = [];
644
+ const runtime = createRuntimeWithClient(
645
+ createMockClient({
646
+ appAgents: async () => ({
647
+ data: [{ name: "agent-mockingbird", mode: "primary" }],
648
+ }),
649
+ prompt: async (request) => {
650
+ seenAgents.push(request.body?.agent);
651
+ return assistantResponse(request.path.id, "OK");
652
+ },
653
+ }),
654
+ );
655
+ runtime.subscribe((event) => {
656
+ events.push(event);
657
+ });
658
+
659
+ const ack = await runtime.sendUserMessage({
660
+ sessionId: "main",
661
+ content: "hello",
662
+ agent: "ghost-agent",
663
+ });
664
+
665
+ expect(ack.messages.at(-1)?.content).toBe("OK");
666
+ expect(seenAgents).toEqual(["build"]);
667
+ const retryEvent = events.find((event) => {
668
+ if (!event || typeof event !== "object") return false;
669
+ const record = event as { type?: string; payload?: { message?: string } };
670
+ return (
671
+ record.type === "session.run.status.updated" &&
672
+ typeof record.payload?.message === "string" &&
673
+ record.payload.message.includes('Requested agent "ghost-agent" is unavailable')
674
+ );
675
+ });
676
+ expect(retryEvent).toBeTruthy();
677
+ });
678
+
679
+ test("retries without explicit agent when OpenCode throws agent.variant error", async () => {
680
+ const seenAgents: Array<string | undefined> = [];
681
+ const runtime = createRuntimeWithClient(
682
+ createMockClient({
683
+ prompt: async (request) => {
684
+ seenAgents.push(request.body?.agent);
685
+ if (request.body?.agent) {
686
+ throw new TypeError("undefined is not an object (evaluating 'agent.variant')");
687
+ }
688
+ return assistantResponse(request.path.id, "Recovered");
689
+ },
690
+ }),
691
+ );
692
+
693
+ const ack = await runtime.sendUserMessage({
694
+ sessionId: "main",
695
+ content: "hello",
696
+ agent: "build",
697
+ });
698
+
699
+ expect(ack.messages.at(-1)?.content).toBe("Recovered");
700
+ expect(seenAgents).toEqual(["build", undefined]);
701
+ });
702
+
703
+ test("uses explicit primary agent id when no agent is provided", async () => {
704
+ const seenAgents: Array<string | undefined> = [];
705
+ const runtime = createRuntimeWithClient(
706
+ createMockClient({
707
+ prompt: async (request) => {
708
+ seenAgents.push(request.body?.agent);
709
+ return assistantResponse(request.path.id, "Recovered via primary agent");
710
+ },
711
+ }),
712
+ );
713
+
714
+ const ack = await runtime.sendUserMessage({
715
+ sessionId: "main",
716
+ content: "hello",
717
+ });
718
+
719
+ expect(ack.messages.at(-1)?.content).toBe("Recovered via primary agent");
720
+ expect(seenAgents).toEqual(["build"]);
721
+ });
722
+
723
+ test("injects memory context once for stable retrieval and re-injects after compaction", async () => {
724
+ updateRuntimeMemoryConfig({
725
+ enabled: true,
726
+ workspaceDir: testWorkspacePath,
727
+ embedProvider: "none",
728
+ toolMode: "hybrid",
729
+ minScore: 0,
730
+ });
731
+ const marker = `marker-${crypto.randomUUID().slice(0, 8)}`;
732
+ await seedMemoryFixture(marker);
733
+
734
+ const promptTexts: string[] = [];
735
+ const runtime = createRuntimeWithClient(
736
+ createMockClient({
737
+ prompt: async (request) => {
738
+ const text = request.body?.parts?.find((part) => part.type === "text")?.text ?? "";
739
+ promptTexts.push(text);
740
+ return assistantResponse(request.path.id, "OK");
741
+ },
742
+ }),
743
+ );
744
+
745
+ try {
746
+ await runtime.sendUserMessage({ sessionId: "main", content: "Durable Memory" });
747
+ await runtime.sendUserMessage({ sessionId: "main", content: "Durable Memory" });
748
+ const internal = runtime as unknown as { handleOpencodeEvent: (event: unknown) => void };
749
+ internal.handleOpencodeEvent({
750
+ type: "session.compacted",
751
+ properties: { sessionID: "ses-1" },
752
+ });
753
+ await runtime.sendUserMessage({ sessionId: "main", content: "Durable Memory" });
754
+ } finally {
755
+ updateRuntimeMemoryConfig({
756
+ enabled: false,
757
+ toolMode: "tool_only",
758
+ minScore: 0.25,
759
+ });
760
+ }
761
+
762
+ expect(promptTexts.length).toBe(3);
763
+ expect(promptTexts[0]?.includes("[Memory Context]")).toBe(true);
764
+ expect(promptTexts[1]?.includes("[Memory Context]")).toBe(false);
765
+ expect(promptTexts[2]?.includes("[Memory Context]")).toBe(true);
766
+ });
767
+
768
+ test("reinjects memory context on 404 session recreation even when current turn was deduped", async () => {
769
+ updateRuntimeMemoryConfig({
770
+ enabled: true,
771
+ workspaceDir: testWorkspacePath,
772
+ embedProvider: "none",
773
+ toolMode: "hybrid",
774
+ minScore: 0,
775
+ });
776
+ const marker = `marker-${crypto.randomUUID().slice(0, 8)}`;
777
+ await seedMemoryFixture(marker);
778
+
779
+ const promptCalls: Array<{ sessionId: string; hasMemoryContext: boolean }> = [];
780
+ let failNextWith404 = false;
781
+ const runtime = createRuntimeWithClient(
782
+ createMockClient({
783
+ prompt: async (request) => {
784
+ const text = request.body?.parts?.find((part) => part.type === "text")?.text ?? "";
785
+ promptCalls.push({
786
+ sessionId: request.path.id,
787
+ hasMemoryContext: text.includes("[Memory Context]"),
788
+ });
789
+ if (failNextWith404) {
790
+ failNextWith404 = false;
791
+ throw Object.assign(new Error("session missing"), { status: 404 });
792
+ }
793
+ return assistantResponse(request.path.id, "OK");
794
+ },
795
+ }),
796
+ );
797
+
798
+ try {
799
+ await runtime.sendUserMessage({ sessionId: "main", content: "Durable Memory" });
800
+ failNextWith404 = true;
801
+ await runtime.sendUserMessage({ sessionId: "main", content: "Durable Memory" });
802
+ } finally {
803
+ updateRuntimeMemoryConfig({
804
+ enabled: false,
805
+ toolMode: "tool_only",
806
+ minScore: 0.25,
807
+ });
808
+ }
809
+
810
+ expect(promptCalls.length).toBe(3);
811
+ expect(promptCalls[0]?.hasMemoryContext).toBe(true);
812
+ expect(promptCalls[1]?.hasMemoryContext).toBe(false);
813
+ expect(promptCalls[2]?.hasMemoryContext).toBe(true);
814
+ expect(promptCalls[1]?.sessionId).not.toBe(promptCalls[2]?.sessionId);
815
+ });
816
+
817
+ test("does not reinject memory context when only retrieval scores change", async () => {
818
+ updateRuntimeMemoryConfig({
819
+ enabled: true,
820
+ workspaceDir: testWorkspacePath,
821
+ embedProvider: "none",
822
+ toolMode: "hybrid",
823
+ minScore: 0,
824
+ });
825
+
826
+ const promptTexts: string[] = [];
827
+ let searchCallCount = 0;
828
+ const runtime = createRuntimeWithClient(
829
+ createMockClient({
830
+ prompt: async (request) => {
831
+ const text = request.body?.parts?.find((part) => part.type === "text")?.text ?? "";
832
+ promptTexts.push(text);
833
+ return assistantResponse(request.path.id, "OK");
834
+ },
835
+ }),
836
+ {
837
+ searchMemoryFn: async () => {
838
+ searchCallCount += 1;
839
+ const score = searchCallCount === 1 ? 0.92 : 0.18;
840
+ return [
841
+ {
842
+ id: "chunk-stable",
843
+ path: "MEMORY.md",
844
+ startLine: 1,
845
+ endLine: 4,
846
+ source: "memory",
847
+ score,
848
+ snippet: "Marker chunk remains stable while scores move.",
849
+ citation: "MEMORY.md#L1",
850
+ },
851
+ ];
852
+ },
853
+ },
854
+ );
855
+
856
+ try {
857
+ await runtime.sendUserMessage({ sessionId: "main", content: "Durable marker detail" });
858
+ await runtime.sendUserMessage({ sessionId: "main", content: "Durable marker detail" });
859
+ } finally {
860
+ updateRuntimeMemoryConfig({
861
+ enabled: false,
862
+ toolMode: "tool_only",
863
+ minScore: 0.25,
864
+ });
865
+ }
866
+
867
+ expect(searchCallCount).toBe(2);
868
+ expect(promptTexts.length).toBe(2);
869
+ expect(promptTexts[0]?.includes("[Memory Context]")).toBe(true);
870
+ expect(promptTexts[1]?.includes("[Memory Context]")).toBe(false);
871
+ });
872
+
873
+ test("suppresses already injected records when retrieval set expands", async () => {
874
+ updateRuntimeMemoryConfig({
875
+ enabled: true,
876
+ workspaceDir: testWorkspacePath,
877
+ embedProvider: "none",
878
+ toolMode: "hybrid",
879
+ minScore: 0,
880
+ injectionDedupeEnabled: true,
881
+ injectionDedupeFallbackRecallOnly: true,
882
+ });
883
+
884
+ const promptTexts: string[] = [];
885
+ let searchCallCount = 0;
886
+ const runtime = createRuntimeWithClient(
887
+ createMockClient({
888
+ prompt: async (request) => {
889
+ const text = request.body?.parts?.find((part) => part.type === "text")?.text ?? "";
890
+ promptTexts.push(text);
891
+ return assistantResponse(request.path.id, "OK");
892
+ },
893
+ }),
894
+ {
895
+ searchMemoryFn: async () => {
896
+ searchCallCount += 1;
897
+ const recordA = [
898
+ "### [memory:memory_a] 2026-03-02T20:41:01.037Z",
899
+ "```json",
900
+ '{"id":"memory_a"}',
901
+ "```",
902
+ "Durable marker alpha detail.",
903
+ ].join("\n");
904
+ const recordB = [
905
+ "### [memory:memory_b] 2026-03-02T20:41:01.037Z",
906
+ "```json",
907
+ '{"id":"memory_b"}',
908
+ "```",
909
+ "Durable marker beta detail.",
910
+ ].join("\n");
911
+ if (searchCallCount === 1) {
912
+ return [
913
+ {
914
+ id: "chunk-a",
915
+ path: "memory/2026-03-02.md",
916
+ startLine: 1,
917
+ endLine: 5,
918
+ source: "memory",
919
+ score: 0.92,
920
+ snippet: recordA,
921
+ citation: "memory/2026-03-02.md#L1",
922
+ },
923
+ ];
924
+ }
925
+ return [
926
+ {
927
+ id: "chunk-a",
928
+ path: "memory/2026-03-02.md",
929
+ startLine: 1,
930
+ endLine: 5,
931
+ source: "memory",
932
+ score: 0.91,
933
+ snippet: recordA,
934
+ citation: "memory/2026-03-02.md#L1",
935
+ },
936
+ {
937
+ id: "chunk-b",
938
+ path: "memory/2026-03-02.md",
939
+ startLine: 7,
940
+ endLine: 11,
941
+ source: "memory",
942
+ score: 0.83,
943
+ snippet: recordB,
944
+ citation: "memory/2026-03-02.md#L7",
945
+ },
946
+ ];
947
+ },
948
+ },
949
+ );
950
+
951
+ try {
952
+ await runtime.sendUserMessage({ sessionId: "main", content: "durable marker status" });
953
+ await runtime.sendUserMessage({ sessionId: "main", content: "durable marker update" });
954
+ } finally {
955
+ updateRuntimeMemoryConfig({
956
+ enabled: false,
957
+ toolMode: "tool_only",
958
+ minScore: 0.25,
959
+ });
960
+ }
961
+
962
+ expect(promptTexts.length).toBe(2);
963
+ expect(promptTexts[0]?.includes("memory:memory_a")).toBe(true);
964
+ expect(promptTexts[1]?.includes("memory:memory_a")).toBe(false);
965
+ expect(promptTexts[1]?.includes("memory:memory_b")).toBe(true);
966
+ });
967
+
968
+ test("allows recall-intent fallback when all relevant records were already injected", async () => {
969
+ updateRuntimeMemoryConfig({
970
+ enabled: true,
971
+ workspaceDir: testWorkspacePath,
972
+ embedProvider: "none",
973
+ toolMode: "hybrid",
974
+ minScore: 0,
975
+ injectionDedupeEnabled: true,
976
+ injectionDedupeFallbackRecallOnly: true,
977
+ });
978
+
979
+ const promptTexts: string[] = [];
980
+ const runtime = createRuntimeWithClient(
981
+ createMockClient({
982
+ prompt: async (request) => {
983
+ const text = request.body?.parts?.find((part) => part.type === "text")?.text ?? "";
984
+ promptTexts.push(text);
985
+ return assistantResponse(request.path.id, "OK");
986
+ },
987
+ }),
988
+ {
989
+ searchMemoryFn: async () => [
990
+ {
991
+ id: "chunk-a",
992
+ path: "memory/2026-03-02.md",
993
+ startLine: 1,
994
+ endLine: 5,
995
+ source: "memory",
996
+ score: 0.92,
997
+ snippet: [
998
+ "### [memory:memory_a] 2026-03-02T20:41:01.037Z",
999
+ "```json",
1000
+ '{"id":"memory_a"}',
1001
+ "```",
1002
+ "Durable marker alpha detail.",
1003
+ ].join("\n"),
1004
+ citation: "memory/2026-03-02.md#L1",
1005
+ },
1006
+ ],
1007
+ },
1008
+ );
1009
+
1010
+ try {
1011
+ await runtime.sendUserMessage({ sessionId: "main", content: "durable marker status" });
1012
+ await runtime.sendUserMessage({ sessionId: "main", content: "what do you remember about me?" });
1013
+ } finally {
1014
+ updateRuntimeMemoryConfig({
1015
+ enabled: false,
1016
+ toolMode: "tool_only",
1017
+ minScore: 0.25,
1018
+ });
1019
+ }
1020
+
1021
+ expect(promptTexts.length).toBe(2);
1022
+ expect(promptTexts[0]?.includes("[Memory Context]")).toBe(true);
1023
+ expect(promptTexts[1]?.includes("[Memory Context]")).toBe(true);
1024
+ expect(promptTexts[1]?.includes("memory:memory_a")).toBe(true);
1025
+ });
1026
+
1027
+ test("skips memory injection for write-intent remember turns in hybrid mode", async () => {
1028
+ updateRuntimeMemoryConfig({
1029
+ enabled: true,
1030
+ workspaceDir: testWorkspacePath,
1031
+ embedProvider: "none",
1032
+ toolMode: "hybrid",
1033
+ minScore: 0,
1034
+ });
1035
+
1036
+ const promptTexts: string[] = [];
1037
+ let searchCalls = 0;
1038
+ const runtime = createRuntimeWithClient(
1039
+ createMockClient({
1040
+ prompt: async (request) => {
1041
+ const text = request.body?.parts?.find((part) => part.type === "text")?.text ?? "";
1042
+ promptTexts.push(text);
1043
+ return assistantResponse(request.path.id, "OK");
1044
+ },
1045
+ }),
1046
+ {
1047
+ searchMemoryFn: async () => {
1048
+ searchCalls += 1;
1049
+ return [
1050
+ {
1051
+ id: "chunk-memory",
1052
+ path: "memory/2026-03-02.md",
1053
+ startLine: 1,
1054
+ endLine: 4,
1055
+ source: "memory",
1056
+ score: 0.8,
1057
+ snippet: "User has an Android phone.",
1058
+ citation: "memory/2026-03-02.md#L1",
1059
+ },
1060
+ ];
1061
+ },
1062
+ },
1063
+ );
1064
+
1065
+ try {
1066
+ await runtime.sendUserMessage({ sessionId: "main", content: "also remember that I have an android phone" });
1067
+ } finally {
1068
+ updateRuntimeMemoryConfig({
1069
+ enabled: false,
1070
+ toolMode: "tool_only",
1071
+ minScore: 0.25,
1072
+ });
1073
+ }
1074
+
1075
+ expect(searchCalls).toBe(0);
1076
+ expect(promptTexts.length).toBe(1);
1077
+ expect(promptTexts[0]).toBe("also remember that I have an android phone");
1078
+ expect(promptTexts[0]?.includes("[Memory Context]")).toBe(false);
1079
+ });
1080
+
1081
+ test("filters low-signal boilerplate memory from injected context when unrelated to query", async () => {
1082
+ updateRuntimeMemoryConfig({
1083
+ enabled: true,
1084
+ workspaceDir: testWorkspacePath,
1085
+ embedProvider: "none",
1086
+ toolMode: "hybrid",
1087
+ minScore: 0,
1088
+ });
1089
+
1090
+ const promptTexts: string[] = [];
1091
+ const runtime = createRuntimeWithClient(
1092
+ createMockClient({
1093
+ prompt: async (request) => {
1094
+ const text = request.body?.parts?.find((part) => part.type === "text")?.text ?? "";
1095
+ promptTexts.push(text);
1096
+ return assistantResponse(request.path.id, "OK");
1097
+ },
1098
+ }),
1099
+ {
1100
+ searchMemoryFn: async () => [
1101
+ {
1102
+ id: "chunk-index",
1103
+ path: "MEMORY.md",
1104
+ startLine: 1,
1105
+ endLine: 4,
1106
+ source: "memory",
1107
+ score: 0.92,
1108
+ snippet:
1109
+ "# Memory Index\nThis file is part of the runtime workspace bundle.\nStore durable notes in `memory/*.md`.",
1110
+ citation: "MEMORY.md#L1",
1111
+ },
1112
+ {
1113
+ id: "chunk-pokemon",
1114
+ path: "memory/2026-03-02.md",
1115
+ startLine: 20,
1116
+ endLine: 24,
1117
+ source: "memory",
1118
+ score: 0.51,
1119
+ snippet: "User's favorite Pokemon is Vulpix.",
1120
+ citation: "memory/2026-03-02.md#L20",
1121
+ },
1122
+ ],
1123
+ },
1124
+ );
1125
+
1126
+ try {
1127
+ await runtime.sendUserMessage({ sessionId: "main", content: "what is my favorite pokemon?" });
1128
+ } finally {
1129
+ updateRuntimeMemoryConfig({
1130
+ enabled: false,
1131
+ toolMode: "tool_only",
1132
+ minScore: 0.25,
1133
+ });
1134
+ }
1135
+
1136
+ expect(promptTexts.length).toBe(1);
1137
+ expect(promptTexts[0]?.includes("[Memory Context]")).toBe(true);
1138
+ expect(promptTexts[0]?.includes("User's favorite Pokemon is Vulpix.")).toBe(true);
1139
+ expect(promptTexts[0]?.includes("This file is part of the runtime workspace bundle.")).toBe(false);
1140
+ });
1141
+
1142
+ test("maps quota errors to RuntimeProviderQuotaError", async () => {
1143
+ const runtime = createRuntimeWithClient(
1144
+ createMockClient({
1145
+ prompt: async () => {
1146
+ throw Object.assign(
1147
+ new Error("You exceeded your current token quota. please check your account balance"),
1148
+ { status: 429 },
1149
+ );
1150
+ },
1151
+ }),
1152
+ );
1153
+
1154
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "hello" })).rejects.toBeInstanceOf(
1155
+ RuntimeProviderQuotaError,
1156
+ );
1157
+ });
1158
+
1159
+ test("fails over to configured model and emits retry status", async () => {
1160
+ const modelCalls: Array<string> = [];
1161
+ const events: Array<unknown> = [];
1162
+ let promptCount = 0;
1163
+
1164
+ const runtime = createRuntimeWithClient(
1165
+ createMockClient({
1166
+ prompt: async (request) => {
1167
+ const providerID = request.body?.model?.providerID ?? "unknown-provider";
1168
+ const modelID = request.body?.model?.modelID ?? "unknown-model";
1169
+ modelCalls.push(`${providerID}/${modelID}`);
1170
+ promptCount += 1;
1171
+ if (promptCount === 1) {
1172
+ throw Object.assign(
1173
+ new Error("You exceeded your current token quota. please check your account balance"),
1174
+ { status: 429 },
1175
+ );
1176
+ }
1177
+ return assistantResponse(request.path.id, "Fallback reply");
1178
+ },
1179
+ }),
1180
+ {
1181
+ fallbackModelRefs: ["backup-provider/backup-model"],
1182
+ },
1183
+ );
1184
+ runtime.subscribe((event) => {
1185
+ events.push(event);
1186
+ });
1187
+
1188
+ const ack = await runtime.sendUserMessage({
1189
+ sessionId: "main",
1190
+ content: "hello",
1191
+ });
1192
+
1193
+ expect(ack.messages.at(-1)?.content).toBe("Fallback reply");
1194
+ expect(modelCalls).toEqual(["test-provider/test-model", "backup-provider/backup-model"]);
1195
+
1196
+ const retryEvent = events.find((event) => {
1197
+ if (!event || typeof event !== "object") return false;
1198
+ const record = event as { type?: string; payload?: { status?: string; attempt?: number; message?: string } };
1199
+ return (
1200
+ record.type === "session.run.status.updated" &&
1201
+ record.payload?.status === "retry" &&
1202
+ record.payload.attempt === 2
1203
+ );
1204
+ }) as { payload?: { message?: string } } | undefined;
1205
+
1206
+ expect(retryEvent).toBeTruthy();
1207
+ expect(retryEvent?.payload?.message).toContain("backup-provider/backup-model");
1208
+ });
1209
+
1210
+ test("emits explicit unavailable-model retry status before configured fallback", async () => {
1211
+ const modelCalls: Array<string> = [];
1212
+ const events: Array<unknown> = [];
1213
+ let promptCount = 0;
1214
+
1215
+ const runtime = createRuntimeWithClient(
1216
+ createMockClient({
1217
+ prompt: async (request) => {
1218
+ const providerID = request.body?.model?.providerID ?? "unknown-provider";
1219
+ const modelID = request.body?.model?.modelID ?? "unknown-model";
1220
+ modelCalls.push(`${providerID}/${modelID}`);
1221
+ promptCount += 1;
1222
+ if (promptCount <= 2) {
1223
+ throw Object.assign(new Error("model not found: test-model"), { status: 404 });
1224
+ }
1225
+ return assistantResponse(request.path.id, "Configured fallback reply");
1226
+ },
1227
+ }),
1228
+ {
1229
+ fallbackModelRefs: ["backup-provider/backup-model"],
1230
+ },
1231
+ );
1232
+ runtime.subscribe((event) => {
1233
+ events.push(event);
1234
+ });
1235
+
1236
+ const ack = await runtime.sendUserMessage({
1237
+ sessionId: "main",
1238
+ content: "hello",
1239
+ });
1240
+
1241
+ expect(ack.messages.at(-1)?.content).toBe("Configured fallback reply");
1242
+ expect(modelCalls).toEqual([
1243
+ "test-provider/test-model",
1244
+ "test-provider/test-model",
1245
+ "backup-provider/backup-model",
1246
+ ]);
1247
+
1248
+ const retryEvent = events.find((event) => {
1249
+ if (!event || typeof event !== "object") return false;
1250
+ const record = event as { type?: string; payload?: { status?: string; attempt?: number; message?: string } };
1251
+ return (
1252
+ record.type === "session.run.status.updated" &&
1253
+ record.payload?.status === "retry" &&
1254
+ record.payload.attempt === 2
1255
+ );
1256
+ }) as { payload?: { message?: string } } | undefined;
1257
+
1258
+ expect(retryEvent).toBeTruthy();
1259
+ expect(retryEvent?.payload?.message).toContain(
1260
+ "Model test-provider/test-model is not available at the selected provider.",
1261
+ );
1262
+ expect(retryEvent?.payload?.message).toContain("Retrying with backup-provider/backup-model.");
1263
+ });
1264
+
1265
+ test("syncs runtime skill paths into OpenCode config without permission skill writes", async () => {
1266
+ const updates: Array<Record<string, unknown>> = [];
1267
+ let configGetCount = 0;
1268
+ const client = createMockClient({
1269
+ prompt: async (request) => assistantResponse(request.path.id, "OK"),
1270
+ });
1271
+ client.config.get = async () => {
1272
+ configGetCount += 1;
1273
+ return {
1274
+ data: {
1275
+ small_model: "test-provider/test-small",
1276
+ skills: {
1277
+ paths: [],
1278
+ },
1279
+ mcp: {
1280
+ github: {
1281
+ enabled: true,
1282
+ },
1283
+ linear: {
1284
+ type: "remote",
1285
+ url: "https://example.com/mcp",
1286
+ enabled: true,
1287
+ },
1288
+ },
1289
+ permission: {},
1290
+ },
1291
+ };
1292
+ };
1293
+ client.config.update = async (input: unknown) => {
1294
+ const body = (input as { body?: Record<string, unknown> }).body ?? {};
1295
+ updates.push(body);
1296
+ return { data: body };
1297
+ };
1298
+
1299
+ const runtime = createRuntimeWithClient(client, {
1300
+ enableSmallModelSync: true,
1301
+ runtimeDirectory: testWorkspacePath,
1302
+ getEnabledSkills: () => ["btca-cli"],
1303
+ getEnabledMcps: () => ["github"],
1304
+ getConfiguredMcpServers: () => [
1305
+ {
1306
+ id: "github",
1307
+ type: "remote",
1308
+ enabled: true,
1309
+ url: "https://api.github.com/mcp",
1310
+ headers: {},
1311
+ oauth: "auto",
1312
+ },
1313
+ ],
1314
+ });
1315
+ expect(configGetCount).toBe(0);
1316
+ const ack = await runtime.sendUserMessage({
1317
+ sessionId: "main",
1318
+ content: "hello",
1319
+ });
1320
+
1321
+ expect(ack.messages.at(-1)?.content).toBe("OK");
1322
+ expect(configGetCount).toBeGreaterThan(0);
1323
+ const updated = updates.at(-1) as
1324
+ | {
1325
+ skills?: { paths?: Array<string> };
1326
+ agent?: Record<
1327
+ string,
1328
+ {
1329
+ model?: string;
1330
+ description?: string;
1331
+ prompt?: string;
1332
+ disable?: boolean;
1333
+ options?: Record<string, unknown>;
1334
+ }
1335
+ >;
1336
+ permission?: Record<string, unknown>;
1337
+ }
1338
+ | undefined;
1339
+ expect(updated).toBeTruthy();
1340
+ expect(updated?.skills?.paths).toContain(path.resolve(testWorkspacePath, ".agents", "skills"));
1341
+ expect(updated?.permission).toEqual({});
1342
+ expect(updated?.agent).toBeUndefined();
1343
+ });
1344
+
1345
+ test("maps authentication errors to RuntimeProviderAuthError", async () => {
1346
+ const runtime = createRuntimeWithClient(
1347
+ createMockClient({
1348
+ prompt: async () => {
1349
+ throw Object.assign(new Error("Unauthorized"), { status: 401 });
1350
+ },
1351
+ }),
1352
+ );
1353
+
1354
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "hello" })).rejects.toBeInstanceOf(
1355
+ RuntimeProviderAuthError,
1356
+ );
1357
+ });
1358
+
1359
+ test("maps rate-limit errors to RuntimeProviderRateLimitError", async () => {
1360
+ const runtime = createRuntimeWithClient(
1361
+ createMockClient({
1362
+ prompt: async () => {
1363
+ throw Object.assign(new Error("Too Many Requests"), { status: 429 });
1364
+ },
1365
+ }),
1366
+ );
1367
+
1368
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "hello" })).rejects.toBeInstanceOf(
1369
+ RuntimeProviderRateLimitError,
1370
+ );
1371
+ });
1372
+
1373
+ test("spawns a background child session with parent linkage", async () => {
1374
+ const createBodies: Array<Record<string, unknown>> = [];
1375
+ let createCount = 0;
1376
+ const runtime = createRuntimeWithClient(
1377
+ createMockClient({
1378
+ create: async (request) => {
1379
+ const body = ((request as { body?: Record<string, unknown> }).body ?? {}) as Record<string, unknown>;
1380
+ createBodies.push(body);
1381
+ createCount += 1;
1382
+ return {
1383
+ data: {
1384
+ id: `ses-${createCount}`,
1385
+ title: createCount === 1 ? "main" : "background",
1386
+ },
1387
+ };
1388
+ },
1389
+ prompt: async (request) => assistantResponse(request.path.id, "OK"),
1390
+ }),
1391
+ );
1392
+
1393
+ const spawned = await runtime.spawnBackgroundSession({
1394
+ parentSessionId: "main",
1395
+ title: "Planner child",
1396
+ requestedBy: "test",
1397
+ });
1398
+
1399
+ expect(spawned.parentSessionId).toBe("main");
1400
+ expect(spawned.parentExternalSessionId).toBe("ses-1");
1401
+ expect(spawned.childExternalSessionId).toBe("ses-2");
1402
+ expect(spawned.status).toBe("created");
1403
+ expect(createBodies[1]?.parentID).toBe("ses-1");
1404
+
1405
+ const listed = await runtime.listBackgroundRuns({ parentSessionId: "main" });
1406
+ expect(listed.some((run) => run.runId === spawned.runId)).toBe(true);
1407
+ });
1408
+
1409
+ test("dispatches async background prompt and completes when session becomes idle", async () => {
1410
+ let createCount = 0;
1411
+ let statusType: "busy" | "idle" = "busy";
1412
+ const asyncPrompts: Array<PromptAsyncInput> = [];
1413
+
1414
+ const runtime = createRuntimeWithClient(
1415
+ createMockClient({
1416
+ create: async () => {
1417
+ createCount += 1;
1418
+ return {
1419
+ data: {
1420
+ id: `ses-${createCount}`,
1421
+ title: createCount === 1 ? "main" : "background",
1422
+ },
1423
+ };
1424
+ },
1425
+ prompt: async (request) => assistantResponse(request.path.id, "OK"),
1426
+ promptAsync: async (request) => {
1427
+ asyncPrompts.push(request);
1428
+ return { data: undefined };
1429
+ },
1430
+ status: async () => ({
1431
+ data: {
1432
+ "ses-2": {
1433
+ type: statusType,
1434
+ },
1435
+ },
1436
+ }),
1437
+ }),
1438
+ );
1439
+
1440
+ const spawned = await runtime.spawnBackgroundSession({
1441
+ parentSessionId: "main",
1442
+ title: "Planner child",
1443
+ });
1444
+
1445
+ const running = await runtime.promptBackgroundAsync({
1446
+ runId: spawned.runId,
1447
+ content: "Investigate and report back.",
1448
+ });
1449
+ expect(running.status).toBe("running");
1450
+ expect(asyncPrompts.length).toBe(1);
1451
+ expect(asyncPrompts[0]?.path.id).toBe("ses-2");
1452
+ expect(asyncPrompts[0]?.body?.parts?.[0]?.text).toBe("Investigate and report back.");
1453
+
1454
+ statusType = "idle";
1455
+ const completed = await runtime.getBackgroundStatus(spawned.runId);
1456
+ expect(completed?.status).toBe("completed");
1457
+ expect(completed?.completedAt).toBeTruthy();
1458
+ });
1459
+
1460
+ test("aborts background runs against the child session id", async () => {
1461
+ let createCount = 0;
1462
+ const abortedSessionIds: Array<string> = [];
1463
+ const client = createMockClient({
1464
+ create: async () => {
1465
+ createCount += 1;
1466
+ return {
1467
+ data: {
1468
+ id: `ses-${createCount}`,
1469
+ title: createCount === 1 ? "main" : "background",
1470
+ },
1471
+ };
1472
+ },
1473
+ prompt: async (request) => assistantResponse(request.path.id, "OK"),
1474
+ });
1475
+ client.session.abort = async (request) => {
1476
+ abortedSessionIds.push((request as { path: { id: string } }).path.id);
1477
+ return { data: true };
1478
+ };
1479
+
1480
+ const runtime = createRuntimeWithClient(client);
1481
+ const spawned = await runtime.spawnBackgroundSession({
1482
+ parentSessionId: "main",
1483
+ title: "Planner child",
1484
+ });
1485
+
1486
+ const aborted = await runtime.abortBackground(spawned.runId);
1487
+ expect(aborted).toBe(true);
1488
+ expect(abortedSessionIds).toEqual(["ses-2"]);
1489
+ });
1490
+
1491
+ test("announces completed background run into the parent session", async () => {
1492
+ let createCount = 0;
1493
+ let statusType: "busy" | "idle" = "busy";
1494
+ const runtime = createRuntimeWithClient(
1495
+ createMockClient({
1496
+ create: async () => {
1497
+ createCount += 1;
1498
+ return {
1499
+ data: {
1500
+ id: `ses-${createCount}`,
1501
+ title: createCount === 1 ? "main" : "background",
1502
+ },
1503
+ };
1504
+ },
1505
+ prompt: async (request) => assistantResponse(request.path.id, "OK"),
1506
+ promptAsync: async () => ({ data: undefined }),
1507
+ status: async () => ({
1508
+ data: {
1509
+ "ses-2": {
1510
+ type: statusType,
1511
+ },
1512
+ },
1513
+ }),
1514
+ messages: async () => ({
1515
+ data: [assistantResponse("ses-2", "Background findings complete.").data],
1516
+ }),
1517
+ }),
1518
+ );
1519
+
1520
+ const spawned = await runtime.spawnBackgroundSession({
1521
+ parentSessionId: "main",
1522
+ title: "Planner child",
1523
+ });
1524
+ await runtime.promptBackgroundAsync({
1525
+ runId: spawned.runId,
1526
+ content: "Investigate.",
1527
+ });
1528
+
1529
+ statusType = "idle";
1530
+ const completed = await runtime.getBackgroundStatus(spawned.runId);
1531
+ expect(completed?.status).toBe("completed");
1532
+
1533
+ await sleep(10);
1534
+ const parentMessages = repository.listMessagesForSession("main");
1535
+ const latest = parentMessages.at(-1);
1536
+ expect(latest?.role).toBe("assistant");
1537
+ expect(latest?.content).toContain(`[Background ${spawned.runId}]`);
1538
+ expect(latest?.content).toContain("Background findings complete.");
1539
+ expect(latest?.content).toContain("Child session:");
1540
+ });
1541
+
1542
+ test("reconciles child sessions via session.children into background runs", async () => {
1543
+ let createCount = 0;
1544
+ const runtime = createRuntimeWithClient(
1545
+ createMockClient({
1546
+ create: async () => {
1547
+ createCount += 1;
1548
+ return {
1549
+ data: {
1550
+ id: `ses-${createCount}`,
1551
+ title: createCount === 1 ? "main" : "background",
1552
+ },
1553
+ };
1554
+ },
1555
+ prompt: async (request) => assistantResponse(request.path.id, "OK"),
1556
+ children: async () => ({
1557
+ data: [
1558
+ {
1559
+ id: "ses-child-1",
1560
+ parentID: "ses-1",
1561
+ title: "Child session",
1562
+ },
1563
+ ],
1564
+ }),
1565
+ }),
1566
+ );
1567
+
1568
+ await runtime.sendUserMessage({
1569
+ sessionId: "main",
1570
+ content: "Seed parent session binding",
1571
+ });
1572
+
1573
+ const syncBackgroundRuns = (runtime as unknown as { syncBackgroundRuns: () => Promise<void> }).syncBackgroundRuns;
1574
+ await syncBackgroundRuns.call(runtime);
1575
+
1576
+ const listed = await runtime.listBackgroundRuns({ parentSessionId: "main", limit: 20 });
1577
+ expect(listed.some((run) => run.childExternalSessionId === "ses-child-1")).toBe(true);
1578
+ });
1579
+
1580
+ test("syncSessionMessages maps reasoning-only assistant parts into visible message content", async () => {
1581
+ let createCount = 0;
1582
+ const runtime = createRuntimeWithClient(
1583
+ createMockClient({
1584
+ create: async () => {
1585
+ createCount += 1;
1586
+ return {
1587
+ data: {
1588
+ id: `ses-${createCount}`,
1589
+ title: createCount === 1 ? "main" : "child",
1590
+ },
1591
+ };
1592
+ },
1593
+ prompt: async (request) => assistantResponse(request.path.id, "Initial response"),
1594
+ messages: async () => ({
1595
+ data: [assistantReasoningOnlyResponse("ses-2", "Subagent completed work item A").data],
1596
+ }),
1597
+ }),
1598
+ );
1599
+
1600
+ const spawned = await runtime.spawnBackgroundSession({
1601
+ parentSessionId: "main",
1602
+ title: "Reasoning child",
1603
+ });
1604
+ expect(spawned.childSessionId).toBeTruthy();
1605
+
1606
+ await runtime.syncSessionMessages(spawned.childSessionId as string);
1607
+
1608
+ const messages = repository.listMessagesForSession(spawned.childSessionId as string);
1609
+ expect(
1610
+ messages.some(message => message.role === "assistant" && message.content.includes("Subagent completed work item A")),
1611
+ ).toBe(true);
1612
+ });
1613
+
1614
+ test("syncSessionMessages replaces stale reasoning content when final text arrives for same message id", async () => {
1615
+ let createCount = 0;
1616
+ let syncCount = 0;
1617
+ const reconciledMessageId = "msg-sync-reconcile-1";
1618
+ const runtime = createRuntimeWithClient(
1619
+ createMockClient({
1620
+ create: async () => {
1621
+ createCount += 1;
1622
+ return {
1623
+ data: {
1624
+ id: `ses-${createCount}`,
1625
+ title: createCount === 1 ? "main" : "child",
1626
+ },
1627
+ };
1628
+ },
1629
+ prompt: async (request) => assistantResponse(request.path.id, "Initial response"),
1630
+ messages: async () => {
1631
+ syncCount += 1;
1632
+ if (syncCount === 1) {
1633
+ const now = Date.now();
1634
+ return {
1635
+ data: [
1636
+ {
1637
+ info: {
1638
+ id: reconciledMessageId,
1639
+ sessionID: "ses-2",
1640
+ role: "assistant",
1641
+ summary: false,
1642
+ mode: "build",
1643
+ finish: "stop",
1644
+ time: {
1645
+ created: now,
1646
+ completed: now,
1647
+ },
1648
+ tokens: {
1649
+ input: 10,
1650
+ output: 20,
1651
+ },
1652
+ cost: 0,
1653
+ },
1654
+ parts: [
1655
+ {
1656
+ type: "reasoning",
1657
+ text: "Planning the response before emitting final text.",
1658
+ time: { start: now, end: now },
1659
+ },
1660
+ ],
1661
+ },
1662
+ ],
1663
+ };
1664
+ }
1665
+ return {
1666
+ data: [assistantResponseWithId("ses-2", reconciledMessageId, "```ts\nconst ok = true;\n```").data],
1667
+ };
1668
+ },
1669
+ }),
1670
+ );
1671
+
1672
+ const spawned = await runtime.spawnBackgroundSession({
1673
+ parentSessionId: "main",
1674
+ title: "Reasoning child",
1675
+ });
1676
+ expect(spawned.childSessionId).toBeTruthy();
1677
+
1678
+ await runtime.syncSessionMessages(spawned.childSessionId as string);
1679
+ await runtime.syncSessionMessages(spawned.childSessionId as string);
1680
+
1681
+ const messages = repository.listMessagesForSession(spawned.childSessionId as string);
1682
+ const reconciled = messages.find(message => message.id === reconciledMessageId);
1683
+ expect(reconciled?.content).toContain("const ok = true;");
1684
+ expect(reconciled?.content).not.toContain("Planning the response before emitting final text.");
1685
+ });
1686
+
1687
+ test("syncSessionMessages reconciles parent session transcripts", async () => {
1688
+ const runtime = createRuntimeWithClient(
1689
+ createMockClient({
1690
+ prompt: async (request) => assistantResponse(request.path.id, "Initial parent reply"),
1691
+ messages: async () => ({
1692
+ data: [assistantResponse("ses-1", "Final consolidated plan from OpenCode").data],
1693
+ }),
1694
+ }),
1695
+ );
1696
+
1697
+ await runtime.sendUserMessage({
1698
+ sessionId: "main",
1699
+ content: "Kick off planning",
1700
+ });
1701
+
1702
+ await runtime.syncSessionMessages("main");
1703
+ const messages = repository.listMessagesForSession("main");
1704
+ expect(messages.some(message => message.content.includes("Final consolidated plan from OpenCode"))).toBe(true);
1705
+ });
1706
+
1707
+ test("message.updated event reconciles parent final assistant message", async () => {
1708
+ const runtime = createRuntimeWithClient(
1709
+ createMockClient({
1710
+ prompt: async (request) => assistantResponse(request.path.id, "Seed response"),
1711
+ message: async () => ({
1712
+ data: assistantResponse("ses-1", "Final plan from message.updated reconciliation").data,
1713
+ }),
1714
+ }),
1715
+ );
1716
+
1717
+ await runtime.sendUserMessage({
1718
+ sessionId: "main",
1719
+ content: "Start run",
1720
+ });
1721
+
1722
+ const handleOpencodeEvent = (runtime as unknown as { handleOpencodeEvent: (event: unknown) => void }).handleOpencodeEvent;
1723
+ handleOpencodeEvent.call(runtime, {
1724
+ type: "message.updated",
1725
+ properties: {
1726
+ info: {
1727
+ id: "msg-final-1",
1728
+ sessionID: "ses-1",
1729
+ role: "assistant",
1730
+ },
1731
+ },
1732
+ });
1733
+
1734
+ await sleep(20);
1735
+ const messages = repository.listMessagesForSession("main");
1736
+ expect(messages.some(message => message.content.includes("Final plan from message.updated reconciliation"))).toBe(true);
1737
+ });
1738
+
1739
+ test("sendUserMessage is resilient when transcript sync already inserted the same assistant message id", async () => {
1740
+ const duplicateAssistantId = "msg-duplicate-assistant";
1741
+ const runtime = createRuntimeWithClient(
1742
+ createMockClient({
1743
+ prompt: async (request) =>
1744
+ assistantResponseWithId(request.path.id, duplicateAssistantId, "Final assistant content from prompt"),
1745
+ }),
1746
+ );
1747
+
1748
+ const seeded = repository.appendAssistantMessage({
1749
+ sessionId: "main",
1750
+ content: "",
1751
+ source: "runtime",
1752
+ messageId: duplicateAssistantId,
1753
+ });
1754
+ expect(seeded?.message.id).toBe(duplicateAssistantId);
1755
+
1756
+ const ack = await runtime.sendUserMessage({
1757
+ sessionId: "main",
1758
+ content: "persist this user turn",
1759
+ });
1760
+
1761
+ expect(ack.messages.some(message => message.role === "assistant" && message.id === duplicateAssistantId)).toBe(true);
1762
+ expect(ack.messages.some(message => message.role === "user" && message.content.includes("persist this user turn"))).toBe(
1763
+ true,
1764
+ );
1765
+
1766
+ const stored = repository.listMessagesForSession("main");
1767
+ expect(stored.filter(message => message.id === duplicateAssistantId).length).toBe(1);
1768
+ expect(
1769
+ stored.filter(
1770
+ message => message.role === "assistant" && message.content.includes("Final assistant content from prompt"),
1771
+ ).length,
1772
+ ).toBe(1);
1773
+ expect(stored.filter(message => message.role === "user" && message.content.includes("persist this user turn")).length).toBe(
1774
+ 1,
1775
+ );
1776
+ });
1777
+
1778
+ test("sendUserMessage reuses assistant parentID to avoid duplicate remote user rows", async () => {
1779
+ const remoteUserId = "msg-user-remote-1";
1780
+ repository.upsertSessionMessages({
1781
+ sessionId: "main",
1782
+ messages: [
1783
+ {
1784
+ id: remoteUserId,
1785
+ role: "user",
1786
+ content: "persist this user turn",
1787
+ createdAt: Date.now() - 10_000,
1788
+ },
1789
+ ],
1790
+ });
1791
+
1792
+ const runtime = createRuntimeWithClient(
1793
+ createMockClient({
1794
+ prompt: async (request) =>
1795
+ assistantResponseWithIds(request.path.id, {
1796
+ id: "msg-assistant-remote-1",
1797
+ parentID: remoteUserId,
1798
+ text: "Final assistant content from prompt",
1799
+ }),
1800
+ }),
1801
+ );
1802
+
1803
+ const ack = await runtime.sendUserMessage({
1804
+ sessionId: "main",
1805
+ content: "persist this user turn",
1806
+ });
1807
+
1808
+ expect(ack.messages.filter(message => message.role === "user" && message.id === remoteUserId).length).toBe(1);
1809
+ const stored = repository.listMessagesForSession("main");
1810
+ expect(stored.filter(message => message.role === "user" && message.id === remoteUserId).length).toBe(1);
1811
+ });
1812
+
1813
+ test("sendUserMessage keeps user turn before assistant when assistant row was synced first", async () => {
1814
+ const remoteUserId = "msg-user-ordered-1";
1815
+ const remoteAssistantId = "msg-assistant-ordered-1";
1816
+ const seededAssistantCreatedAt = Date.now() - 30_000;
1817
+ repository.appendAssistantMessage({
1818
+ sessionId: "main",
1819
+ content: "",
1820
+ source: "runtime",
1821
+ createdAt: seededAssistantCreatedAt,
1822
+ messageId: remoteAssistantId,
1823
+ });
1824
+
1825
+ const runtime = createRuntimeWithClient(
1826
+ createMockClient({
1827
+ prompt: async (request) =>
1828
+ assistantResponseWithIds(request.path.id, {
1829
+ id: remoteAssistantId,
1830
+ parentID: remoteUserId,
1831
+ text: "Ordered assistant reply",
1832
+ }),
1833
+ }),
1834
+ );
1835
+
1836
+ await runtime.sendUserMessage({
1837
+ sessionId: "main",
1838
+ content: "ordered user turn",
1839
+ });
1840
+
1841
+ const stored = repository.listMessagesForSession("main");
1842
+ const userIndex = stored.findIndex(message => message.id === remoteUserId);
1843
+ const assistantIndex = stored.findIndex(message => message.id === remoteAssistantId);
1844
+ expect(userIndex).toBeGreaterThanOrEqual(0);
1845
+ expect(assistantIndex).toBeGreaterThanOrEqual(0);
1846
+ expect(userIndex).toBeLessThan(assistantIndex);
1847
+
1848
+ const user = stored.find(message => message.id === remoteUserId);
1849
+ const assistant = stored.find(message => message.id === remoteAssistantId);
1850
+ expect(user).toBeTruthy();
1851
+ expect(assistant).toBeTruthy();
1852
+ expect(Date.parse(user?.at ?? "")).toBeLessThanOrEqual(Date.parse(assistant?.at ?? ""));
1853
+ });
1854
+
1855
+ test("appendChatExchange aligns newly inserted user timestamp when assistant exists first", () => {
1856
+ const userMessageId = "msg-user-aligned-1";
1857
+ const assistantMessageId = "msg-assistant-aligned-1";
1858
+ const assistantCreatedAt = Date.now() - 20_000;
1859
+ repository.appendAssistantMessage({
1860
+ sessionId: "main",
1861
+ content: "",
1862
+ source: "runtime",
1863
+ createdAt: assistantCreatedAt,
1864
+ messageId: assistantMessageId,
1865
+ });
1866
+
1867
+ const appended = repository.appendChatExchange({
1868
+ sessionId: "main",
1869
+ userContent: "hello",
1870
+ assistantContent: "world",
1871
+ source: "runtime",
1872
+ createdAt: Date.now(),
1873
+ userMessageId,
1874
+ assistantMessageId,
1875
+ usage: {
1876
+ requestCountDelta: 1,
1877
+ inputTokensDelta: 1,
1878
+ outputTokensDelta: 1,
1879
+ estimatedCostUsdDelta: 0,
1880
+ },
1881
+ });
1882
+ expect(appended).toBeTruthy();
1883
+
1884
+ const messages = repository.listMessagesForSession("main");
1885
+ const user = messages.find(message => message.id === userMessageId);
1886
+ const assistant = messages.find(message => message.id === assistantMessageId);
1887
+ expect(user).toBeTruthy();
1888
+ expect(assistant).toBeTruthy();
1889
+ expect(Date.parse(user?.at ?? "")).toBeLessThanOrEqual(Date.parse(assistant?.at ?? ""));
1890
+ });
1891
+
1892
+ test("appendChatExchange backfills assistant timestamp at or after existing user timestamp", () => {
1893
+ const userMessageId = "msg-user-preexisting-1";
1894
+ const assistantMessageId = "msg-assistant-backfill-1";
1895
+ const userCreatedAt = Date.now() - 10_000;
1896
+ repository.upsertSessionMessages({
1897
+ sessionId: "main",
1898
+ messages: [
1899
+ {
1900
+ id: userMessageId,
1901
+ role: "user",
1902
+ content: "preexisting user",
1903
+ createdAt: userCreatedAt,
1904
+ },
1905
+ ],
1906
+ });
1907
+
1908
+ const appended = repository.appendChatExchange({
1909
+ sessionId: "main",
1910
+ userContent: "preexisting user",
1911
+ assistantContent: "assistant backfill",
1912
+ source: "runtime",
1913
+ createdAt: userCreatedAt - 2_000,
1914
+ userMessageId,
1915
+ assistantMessageId,
1916
+ usage: {
1917
+ requestCountDelta: 1,
1918
+ inputTokensDelta: 1,
1919
+ outputTokensDelta: 1,
1920
+ estimatedCostUsdDelta: 0,
1921
+ },
1922
+ });
1923
+ expect(appended).toBeTruthy();
1924
+
1925
+ const messages = repository.listMessagesForSession("main");
1926
+ const user = messages.find(message => message.id === userMessageId);
1927
+ const assistant = messages.find(message => message.id === assistantMessageId);
1928
+ expect(user).toBeTruthy();
1929
+ expect(assistant).toBeTruthy();
1930
+ expect(Date.parse(assistant?.at ?? "")).toBeGreaterThanOrEqual(Date.parse(user?.at ?? ""));
1931
+ });
1932
+
1933
+ test("appendChatExchange repairs reversed preexisting pair ordering", () => {
1934
+ const userMessageId = "msg-user-repair-1";
1935
+ const assistantMessageId = "msg-assistant-repair-1";
1936
+ const assistantCreatedAt = Date.now() - 25_000;
1937
+ const userCreatedAt = Date.now() - 5_000;
1938
+ repository.appendAssistantMessage({
1939
+ sessionId: "main",
1940
+ content: "assistant early",
1941
+ source: "runtime",
1942
+ createdAt: assistantCreatedAt,
1943
+ messageId: assistantMessageId,
1944
+ });
1945
+ repository.upsertSessionMessages({
1946
+ sessionId: "main",
1947
+ messages: [
1948
+ {
1949
+ id: userMessageId,
1950
+ role: "user",
1951
+ content: "user late",
1952
+ createdAt: userCreatedAt,
1953
+ },
1954
+ ],
1955
+ });
1956
+
1957
+ const appended = repository.appendChatExchange({
1958
+ sessionId: "main",
1959
+ userContent: "user late",
1960
+ assistantContent: "assistant early",
1961
+ source: "runtime",
1962
+ createdAt: Date.now(),
1963
+ userMessageId,
1964
+ assistantMessageId,
1965
+ usage: {
1966
+ requestCountDelta: 1,
1967
+ inputTokensDelta: 1,
1968
+ outputTokensDelta: 1,
1969
+ estimatedCostUsdDelta: 0,
1970
+ },
1971
+ });
1972
+ expect(appended).toBeTruthy();
1973
+
1974
+ const messages = repository.listMessagesForSession("main");
1975
+ const user = messages.find(message => message.id === userMessageId);
1976
+ const assistant = messages.find(message => message.id === assistantMessageId);
1977
+ expect(user).toBeTruthy();
1978
+ expect(assistant).toBeTruthy();
1979
+ expect(Date.parse(user?.at ?? "")).toBeLessThanOrEqual(Date.parse(assistant?.at ?? ""));
1980
+ });
1981
+
1982
+ test("message.updated skips transcript sync while local session is busy", async () => {
1983
+ let messageSyncCalls = 0;
1984
+ const runtime = createRuntimeWithClient(
1985
+ createMockClient({
1986
+ prompt: async (request) => assistantResponse(request.path.id, "Seed response"),
1987
+ message: async () => {
1988
+ messageSyncCalls += 1;
1989
+ return {
1990
+ data: assistantResponse("ses-1", "Should not sync while busy").data,
1991
+ };
1992
+ },
1993
+ }),
1994
+ );
1995
+
1996
+ await runtime.sendUserMessage({
1997
+ sessionId: "main",
1998
+ content: "Start run",
1999
+ });
2000
+
2001
+ const internal = runtime as unknown as {
2002
+ busySessions: Set<string>;
2003
+ handleOpencodeEvent: (event: unknown) => void;
2004
+ };
2005
+ internal.busySessions.add("main");
2006
+ internal.handleOpencodeEvent({
2007
+ type: "message.updated",
2008
+ properties: {
2009
+ info: {
2010
+ id: "msg-busy-sync-1",
2011
+ sessionID: "ses-1",
2012
+ role: "assistant",
2013
+ },
2014
+ },
2015
+ });
2016
+
2017
+ await sleep(20);
2018
+ expect(messageSyncCalls).toBe(0);
2019
+ });
2020
+
2021
+ test("message.part.updated emits session.message.delta for assistant text updates", async () => {
2022
+ const events: Array<unknown> = [];
2023
+ const runtime = createRuntimeWithClient(
2024
+ createMockClient({
2025
+ prompt: async (request) => assistantResponse(request.path.id, "Seed response"),
2026
+ }),
2027
+ );
2028
+ runtime.subscribe(event => {
2029
+ events.push(event);
2030
+ });
2031
+
2032
+ await runtime.sendUserMessage({
2033
+ sessionId: "main",
2034
+ content: "Start run",
2035
+ });
2036
+ events.length = 0;
2037
+
2038
+ const handleOpencodeEvent = (runtime as unknown as { handleOpencodeEvent: (event: unknown) => void }).handleOpencodeEvent;
2039
+ handleOpencodeEvent.call(runtime, {
2040
+ type: "message.updated",
2041
+ properties: {
2042
+ info: {
2043
+ id: "msg-stream-1",
2044
+ sessionID: "ses-1",
2045
+ role: "assistant",
2046
+ },
2047
+ },
2048
+ });
2049
+ handleOpencodeEvent.call(runtime, {
2050
+ type: "message.part.updated",
2051
+ properties: {
2052
+ delta: "Hel",
2053
+ part: {
2054
+ id: "part-text-1",
2055
+ sessionID: "ses-1",
2056
+ messageID: "msg-stream-1",
2057
+ type: "text",
2058
+ text: "Hello",
2059
+ time: { start: Date.now() },
2060
+ },
2061
+ },
2062
+ });
2063
+
2064
+ const deltaEvent = events.find(event => {
2065
+ if (!event || typeof event !== "object") return false;
2066
+ const record = event as {
2067
+ type?: string;
2068
+ payload?: {
2069
+ sessionId?: string;
2070
+ messageId?: string;
2071
+ mode?: string;
2072
+ text?: string;
2073
+ };
2074
+ };
2075
+ return (
2076
+ record.type === "session.message.delta" &&
2077
+ record.payload?.sessionId === "main" &&
2078
+ record.payload?.messageId === "msg-stream-1" &&
2079
+ record.payload?.mode === "append" &&
2080
+ record.payload?.text === "Hel"
2081
+ );
2082
+ });
2083
+ expect(deltaEvent).toBeTruthy();
2084
+ });
2085
+
2086
+ test("message.part.delta emits session.message.delta when assistant role metadata is known", async () => {
2087
+ const events: Array<unknown> = [];
2088
+ const runtime = createRuntimeWithClient(
2089
+ createMockClient({
2090
+ prompt: async (request) => assistantResponse(request.path.id, "Seed response"),
2091
+ }),
2092
+ );
2093
+ runtime.subscribe(event => {
2094
+ events.push(event);
2095
+ });
2096
+
2097
+ await runtime.sendUserMessage({
2098
+ sessionId: "main",
2099
+ content: "Start run",
2100
+ });
2101
+ events.length = 0;
2102
+
2103
+ const handleOpencodeEvent = (runtime as unknown as { handleOpencodeEvent: (event: unknown) => void }).handleOpencodeEvent;
2104
+ handleOpencodeEvent.call(runtime, {
2105
+ type: "message.updated",
2106
+ properties: {
2107
+ info: {
2108
+ id: "msg-stream-2",
2109
+ sessionID: "ses-1",
2110
+ role: "assistant",
2111
+ },
2112
+ },
2113
+ });
2114
+ handleOpencodeEvent.call(runtime, {
2115
+ type: "message.part.updated",
2116
+ properties: {
2117
+ part: {
2118
+ id: "part-text-2",
2119
+ sessionID: "ses-1",
2120
+ messageID: "msg-stream-2",
2121
+ type: "text",
2122
+ text: "H",
2123
+ time: { start: Date.now() },
2124
+ },
2125
+ },
2126
+ });
2127
+ events.length = 0;
2128
+
2129
+ handleOpencodeEvent.call(runtime, {
2130
+ type: "message.part.delta",
2131
+ properties: {
2132
+ sessionID: "ses-1",
2133
+ messageID: "msg-stream-2",
2134
+ partID: "part-text-2",
2135
+ field: "text",
2136
+ delta: "ello",
2137
+ },
2138
+ });
2139
+
2140
+ const deltaEvent = events.find(event => {
2141
+ if (!event || typeof event !== "object") return false;
2142
+ const record = event as {
2143
+ type?: string;
2144
+ payload?: {
2145
+ sessionId?: string;
2146
+ messageId?: string;
2147
+ mode?: string;
2148
+ text?: string;
2149
+ };
2150
+ };
2151
+ return (
2152
+ record.type === "session.message.delta" &&
2153
+ record.payload?.sessionId === "main" &&
2154
+ record.payload?.messageId === "msg-stream-2" &&
2155
+ record.payload?.mode === "append" &&
2156
+ record.payload?.text === "ello"
2157
+ );
2158
+ });
2159
+ expect(deltaEvent).toBeTruthy();
2160
+ });
2161
+
2162
+ test("permission/question events map to runtime prompt events", async () => {
2163
+ const events: Array<unknown> = [];
2164
+ const runtime = createRuntimeWithClient(
2165
+ createMockClient({
2166
+ prompt: async (request) => assistantResponse(request.path.id, "Seed response"),
2167
+ }),
2168
+ );
2169
+ runtime.subscribe(event => {
2170
+ events.push(event);
2171
+ });
2172
+
2173
+ await runtime.sendUserMessage({
2174
+ sessionId: "main",
2175
+ content: "Start run",
2176
+ });
2177
+ events.length = 0;
2178
+
2179
+ const handleOpencodeEvent = (runtime as unknown as { handleOpencodeEvent: (event: unknown) => void }).handleOpencodeEvent;
2180
+ handleOpencodeEvent.call(runtime, {
2181
+ type: "permission.asked",
2182
+ properties: {
2183
+ id: "perm-1",
2184
+ sessionID: "ses-1",
2185
+ permission: "Read",
2186
+ patterns: ["/tmp/*"],
2187
+ metadata: {},
2188
+ always: [],
2189
+ },
2190
+ });
2191
+ handleOpencodeEvent.call(runtime, {
2192
+ type: "permission.replied",
2193
+ properties: {
2194
+ sessionID: "ses-1",
2195
+ requestID: "perm-1",
2196
+ reply: "once",
2197
+ },
2198
+ });
2199
+ handleOpencodeEvent.call(runtime, {
2200
+ type: "question.asked",
2201
+ properties: {
2202
+ id: "question-1",
2203
+ sessionID: "ses-1",
2204
+ questions: [
2205
+ {
2206
+ question: "Pick one",
2207
+ header: "pick",
2208
+ options: [{ label: "A", description: "Option A" }],
2209
+ },
2210
+ ],
2211
+ },
2212
+ });
2213
+ handleOpencodeEvent.call(runtime, {
2214
+ type: "question.replied",
2215
+ properties: {
2216
+ sessionID: "ses-1",
2217
+ requestID: "question-1",
2218
+ },
2219
+ });
2220
+ handleOpencodeEvent.call(runtime, {
2221
+ type: "question.rejected",
2222
+ properties: {
2223
+ sessionID: "ses-1",
2224
+ requestID: "question-2",
2225
+ },
2226
+ });
2227
+
2228
+ const permissionRequested = events.find(event => {
2229
+ if (!event || typeof event !== "object") return false;
2230
+ const record = event as { type?: string; payload?: { id?: string; sessionId?: string } };
2231
+ return record.type === "session.permission.requested" && record.payload?.id === "perm-1" && record.payload?.sessionId === "main";
2232
+ });
2233
+ expect(permissionRequested).toBeTruthy();
2234
+
2235
+ const permissionResolved = events.find(event => {
2236
+ if (!event || typeof event !== "object") return false;
2237
+ const record = event as { type?: string; payload?: { requestId?: string; reply?: string } };
2238
+ return record.type === "session.permission.resolved" && record.payload?.requestId === "perm-1" && record.payload?.reply === "once";
2239
+ });
2240
+ expect(permissionResolved).toBeTruthy();
2241
+
2242
+ const questionRequested = events.find(event => {
2243
+ if (!event || typeof event !== "object") return false;
2244
+ const record = event as {
2245
+ type?: string;
2246
+ payload?: {
2247
+ id?: string;
2248
+ sessionId?: string;
2249
+ questions?: Array<{ question?: string }>;
2250
+ };
2251
+ };
2252
+ return (
2253
+ record.type === "session.question.requested" &&
2254
+ record.payload?.id === "question-1" &&
2255
+ record.payload?.sessionId === "main" &&
2256
+ record.payload.questions?.[0]?.question === "Pick one"
2257
+ );
2258
+ });
2259
+ expect(questionRequested).toBeTruthy();
2260
+
2261
+ const questionReplied = events.find(event => {
2262
+ if (!event || typeof event !== "object") return false;
2263
+ const record = event as { type?: string; payload?: { requestId?: string; outcome?: string } };
2264
+ return record.type === "session.question.resolved" && record.payload?.requestId === "question-1" && record.payload?.outcome === "replied";
2265
+ });
2266
+ expect(questionReplied).toBeTruthy();
2267
+
2268
+ const questionRejected = events.find(event => {
2269
+ if (!event || typeof event !== "object") return false;
2270
+ const record = event as { type?: string; payload?: { requestId?: string; outcome?: string } };
2271
+ return record.type === "session.question.resolved" && record.payload?.requestId === "question-2" && record.payload?.outcome === "rejected";
2272
+ });
2273
+ expect(questionRejected).toBeTruthy();
2274
+ });
2275
+
2276
+ test("streamed message metadata caches evict oldest entries when over limit", () => {
2277
+ const runtime = createRuntimeWithClient(
2278
+ createMockClient({
2279
+ prompt: async (request) => assistantResponse(request.path.id, "Seed response"),
2280
+ }),
2281
+ );
2282
+ const internal = runtime as unknown as {
2283
+ rememberMessageRole: (sessionId: string, messageId: string, role: "assistant" | "user") => void;
2284
+ rememberPartMetadata: (part: unknown) => void;
2285
+ messageRoleByScopedMessageId: Map<string, "assistant" | "user">;
2286
+ partTypeByScopedPartId: Map<string, string>;
2287
+ };
2288
+
2289
+ const limit = 10_000;
2290
+ const totalEntries = limit + 250;
2291
+ for (let index = 0; index < totalEntries; index += 1) {
2292
+ const messageId = `msg-cache-${index}`;
2293
+ const partId = `part-cache-${index}`;
2294
+ internal.rememberMessageRole("ses-1", messageId, "assistant");
2295
+ internal.rememberPartMetadata({
2296
+ id: partId,
2297
+ sessionID: "ses-1",
2298
+ messageID: messageId,
2299
+ type: "text",
2300
+ });
2301
+ }
2302
+
2303
+ expect(internal.messageRoleByScopedMessageId.size).toBe(limit);
2304
+ expect(internal.partTypeByScopedPartId.size).toBe(limit);
2305
+ expect(internal.messageRoleByScopedMessageId.has("ses-1:msg-cache-0")).toBe(false);
2306
+ expect(internal.partTypeByScopedPartId.has("ses-1:msg-cache-0:part-cache-0")).toBe(false);
2307
+ expect(internal.messageRoleByScopedMessageId.has(`ses-1:msg-cache-${totalEntries - 1}`)).toBe(true);
2308
+ expect(internal.partTypeByScopedPartId.has(`ses-1:msg-cache-${totalEntries - 1}:part-cache-${totalEntries - 1}`)).toBe(
2309
+ true,
2310
+ );
2311
+ });
2312
+
2313
+ test("memory injection state evicts oldest sessions when over limit", () => {
2314
+ const runtime = createRuntimeWithClient(
2315
+ createMockClient({
2316
+ prompt: async (request) => assistantResponse(request.path.id, "Seed response"),
2317
+ }),
2318
+ );
2319
+ const internal = runtime as unknown as {
2320
+ setMemoryInjectionState: (sessionId: string, state: {
2321
+ fingerprint: string;
2322
+ forceReinject: boolean;
2323
+ generation: number;
2324
+ turn: number;
2325
+ injectedKeysByGeneration: string[];
2326
+ }) => void;
2327
+ memoryInjectionStateBySessionId: Map<string, unknown>;
2328
+ };
2329
+
2330
+ const totalEntries = 1_250;
2331
+ for (let index = 0; index < totalEntries; index += 1) {
2332
+ internal.setMemoryInjectionState(`ses-memory-${index}`, {
2333
+ fingerprint: `fp-${index}`,
2334
+ forceReinject: false,
2335
+ generation: index,
2336
+ turn: index,
2337
+ injectedKeysByGeneration: [],
2338
+ });
2339
+ }
2340
+
2341
+ expect(internal.memoryInjectionStateBySessionId.size).toBe(1_000);
2342
+ expect(internal.memoryInjectionStateBySessionId.has("ses-memory-0")).toBe(false);
2343
+ expect(internal.memoryInjectionStateBySessionId.has(`ses-memory-${totalEntries - 1}`)).toBe(true);
2344
+ });
2345
+
2346
+ test("session.idle triggers best-effort parent transcript sync", async () => {
2347
+ const runtime = createRuntimeWithClient(
2348
+ createMockClient({
2349
+ prompt: async (request) => assistantResponse(request.path.id, "Seed response"),
2350
+ messages: async () => ({
2351
+ data: [assistantResponse("ses-1", "Final parent plan synced on idle").data],
2352
+ }),
2353
+ }),
2354
+ );
2355
+
2356
+ await runtime.sendUserMessage({
2357
+ sessionId: "main",
2358
+ content: "Start run",
2359
+ });
2360
+
2361
+ const handleOpencodeEvent = (runtime as unknown as { handleOpencodeEvent: (event: unknown) => void }).handleOpencodeEvent;
2362
+ handleOpencodeEvent.call(runtime, {
2363
+ type: "session.idle",
2364
+ properties: {
2365
+ sessionID: "ses-1",
2366
+ },
2367
+ });
2368
+
2369
+ await sleep(20);
2370
+ const messages = repository.listMessagesForSession("main");
2371
+ expect(messages.some(message => message.content.includes("Final parent plan synced on idle"))).toBe(true);
2372
+ });
2373
+
2374
+ test("no-text assistant prompt response persists partial assistant content", async () => {
2375
+ const now = Date.now();
2376
+ const runtime = createRuntimeWithClient(
2377
+ createMockClient({
2378
+ prompt: async (request) => ({
2379
+ data: {
2380
+ info: {
2381
+ id: "msg-partial-1",
2382
+ sessionID: request.path.id,
2383
+ role: "assistant",
2384
+ summary: false,
2385
+ mode: "build",
2386
+ finish: "tool_calls",
2387
+ time: {
2388
+ created: now,
2389
+ completed: now,
2390
+ },
2391
+ tokens: {
2392
+ input: 8,
2393
+ output: 12,
2394
+ },
2395
+ cost: 0,
2396
+ },
2397
+ parts: [
2398
+ {
2399
+ id: "reasoning-1",
2400
+ type: "reasoning",
2401
+ text: "Working through subtasks before finalizing.",
2402
+ time: { start: now, end: now },
2403
+ },
2404
+ ],
2405
+ },
2406
+ }),
2407
+ }),
2408
+ );
2409
+
2410
+ const ack = await runtime.sendUserMessage({
2411
+ sessionId: "main",
2412
+ content: "Do work in parallel",
2413
+ });
2414
+
2415
+ expect(ack.messages.at(-1)?.role).toBe("assistant");
2416
+ expect(ack.messages.at(-1)?.content).toContain("Working through subtasks before finalizing.");
2417
+ });
2418
+
2419
+ test("health check runs prompt probe and serves cached result", async () => {
2420
+ let promptCount = 0;
2421
+ const runtime = createRuntimeWithClient(
2422
+ createMockClient({
2423
+ prompt: async (request) => {
2424
+ promptCount += 1;
2425
+ return assistantResponse(request.path.id, "OK");
2426
+ },
2427
+ }),
2428
+ );
2429
+
2430
+ const first = await runtime.checkHealth({ force: true });
2431
+ expect(first.ok).toBe(true);
2432
+ expect(first.fromCache).toBe(false);
2433
+ expect(first.responseText).toContain("OK");
2434
+
2435
+ const second = await runtime.checkHealth();
2436
+ expect(second.ok).toBe(true);
2437
+ expect(second.fromCache).toBe(true);
2438
+ expect(promptCount).toBe(1);
2439
+ });
2440
+
2441
+ test("health check maps provider auth failures", async () => {
2442
+ const runtime = createRuntimeWithClient(
2443
+ createMockClient({
2444
+ prompt: async () => {
2445
+ throw Object.assign(new Error("Unauthorized"), { status: 401 });
2446
+ },
2447
+ }),
2448
+ );
2449
+
2450
+ const health = await runtime.checkHealth({ force: true });
2451
+ expect(health.ok).toBe(false);
2452
+ expect(health.error?.name).toBe("RuntimeProviderAuthError");
2453
+ });
2454
+
2455
+ test("health check maps provider quota failures", async () => {
2456
+ const runtime = createRuntimeWithClient(
2457
+ createMockClient({
2458
+ prompt: async () => {
2459
+ throw Object.assign(
2460
+ new Error("You exceeded your current token quota. please check your account balance"),
2461
+ { status: 429 },
2462
+ );
2463
+ },
2464
+ }),
2465
+ );
2466
+
2467
+ const health = await runtime.checkHealth({ force: true });
2468
+ expect(health.ok).toBe(false);
2469
+ expect(health.error?.name).toBe("RuntimeProviderQuotaError");
2470
+ });
2471
+
2472
+ test("health check maps probe timeouts", async () => {
2473
+ const runtime = createRuntimeWithClient(
2474
+ createMockClient({
2475
+ prompt: async (request) => {
2476
+ return await new Promise((_resolve, reject) => {
2477
+ const timer = setTimeout(() => {
2478
+ reject(new Error("health probe should have been aborted"));
2479
+ }, 2_000);
2480
+ const signal = request.signal;
2481
+ if (!signal) {
2482
+ clearTimeout(timer);
2483
+ reject(new Error("missing health probe signal"));
2484
+ return;
2485
+ }
2486
+ if (signal.aborted) {
2487
+ clearTimeout(timer);
2488
+ reject(new DOMException("Aborted", "AbortError"));
2489
+ return;
2490
+ }
2491
+ signal.addEventListener(
2492
+ "abort",
2493
+ () => {
2494
+ clearTimeout(timer);
2495
+ reject(new DOMException("Aborted", "AbortError"));
2496
+ },
2497
+ { once: true },
2498
+ );
2499
+ });
2500
+ },
2501
+ }),
2502
+ );
2503
+
2504
+ const health = await runtime.checkHealth({ force: true });
2505
+ expect(health.ok).toBe(false);
2506
+ expect(health.error?.message).toContain("timed out");
2507
+ });
2508
+
2509
+ test("applies prompt timeout signal to blocking prompt requests", async () => {
2510
+ const runtime = createRuntimeWithClient(
2511
+ createMockClient({
2512
+ prompt: async (request) => {
2513
+ return await new Promise((_resolve, reject) => {
2514
+ const timer = setTimeout(() => {
2515
+ reject(new Error("prompt should have been aborted"));
2516
+ }, 200);
2517
+ const signal = request.signal;
2518
+ if (!signal) {
2519
+ clearTimeout(timer);
2520
+ reject(new Error("missing prompt timeout signal"));
2521
+ return;
2522
+ }
2523
+ if (signal.aborted) {
2524
+ clearTimeout(timer);
2525
+ reject(new DOMException("Aborted", "AbortError"));
2526
+ return;
2527
+ }
2528
+ signal.addEventListener(
2529
+ "abort",
2530
+ () => {
2531
+ clearTimeout(timer);
2532
+ reject(new DOMException("Aborted", "AbortError"));
2533
+ },
2534
+ { once: true },
2535
+ );
2536
+ });
2537
+ },
2538
+ }),
2539
+ );
2540
+
2541
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "hello" })).rejects.toMatchObject({
2542
+ name: "AbortError",
2543
+ });
2544
+ });
2545
+
2546
+ test("throws RuntimeContinuationDetachedError when prompt times out and children are still in-flight", async () => {
2547
+ const runtime = createRuntimeWithClient(
2548
+ createMockClient({
2549
+ prompt: async (request) => {
2550
+ return await new Promise((_resolve, reject) => {
2551
+ const timer = setTimeout(() => {
2552
+ reject(new Error("prompt should have been aborted"));
2553
+ }, 300);
2554
+ const signal = request.signal;
2555
+ if (!signal) {
2556
+ clearTimeout(timer);
2557
+ reject(new Error("missing prompt timeout signal"));
2558
+ return;
2559
+ }
2560
+ signal.addEventListener(
2561
+ "abort",
2562
+ () => {
2563
+ clearTimeout(timer);
2564
+ reject(new DOMException("Aborted", "AbortError"));
2565
+ },
2566
+ { once: true },
2567
+ );
2568
+ });
2569
+ },
2570
+ }),
2571
+ );
2572
+
2573
+ let childCheckCalls = 0;
2574
+ (runtime as unknown as { inFlightBackgroundChildRunCount: (_sessionId: string) => number }).inFlightBackgroundChildRunCount =
2575
+ () => {
2576
+ childCheckCalls += 1;
2577
+ return childCheckCalls === 1 ? 0 : 1;
2578
+ };
2579
+
2580
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "hello" })).rejects.toBeInstanceOf(
2581
+ RuntimeContinuationDetachedError,
2582
+ );
2583
+ });
2584
+
2585
+ test("throws RuntimeContinuationDetachedError for timeout-like errors when children are still in-flight", async () => {
2586
+ const runtime = createRuntimeWithClient(
2587
+ createMockClient({
2588
+ prompt: async () => {
2589
+ throw new Error("The operation timed out.");
2590
+ },
2591
+ }),
2592
+ );
2593
+
2594
+ let childCheckCalls = 0;
2595
+ (runtime as unknown as { inFlightBackgroundChildRunCount: (_sessionId: string) => number }).inFlightBackgroundChildRunCount =
2596
+ () => {
2597
+ childCheckCalls += 1;
2598
+ return childCheckCalls === 1 ? 0 : 1;
2599
+ };
2600
+
2601
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "hello" })).rejects.toBeInstanceOf(
2602
+ RuntimeContinuationDetachedError,
2603
+ );
2604
+ });
2605
+
2606
+ test("suppresses session.run.error timeout events while child runs are in-flight", async () => {
2607
+ const events: Array<unknown> = [];
2608
+ const runtime = createRuntimeWithClient(
2609
+ createMockClient({
2610
+ prompt: async (request) => assistantResponse(request.path.id, "ok"),
2611
+ }),
2612
+ );
2613
+ runtime.subscribe(event => {
2614
+ events.push(event);
2615
+ });
2616
+
2617
+ await runtime.sendUserMessage({ sessionId: "main", content: "bootstrap" });
2618
+ events.length = 0;
2619
+
2620
+ const internal = runtime as unknown as {
2621
+ inFlightBackgroundChildRunCount: (_sessionId: string) => number;
2622
+ markBackgroundRunFailed: (_sessionId: string, _message: string) => void;
2623
+ handleSessionErrorEvent: (event: unknown) => void;
2624
+ };
2625
+ internal.inFlightBackgroundChildRunCount = () => 2;
2626
+ internal.markBackgroundRunFailed = () => {};
2627
+ internal.handleSessionErrorEvent({
2628
+ type: "session.error",
2629
+ properties: {
2630
+ sessionID: "ses-1",
2631
+ error: new Error("The operation timed out."),
2632
+ },
2633
+ });
2634
+
2635
+ const runErrorEvent = events.find(event => {
2636
+ if (!event || typeof event !== "object") return false;
2637
+ return (event as { type?: string }).type === "session.run.error";
2638
+ });
2639
+ expect(runErrorEvent).toBeUndefined();
2640
+
2641
+ const busyStatusEvent = events.find(event => {
2642
+ if (!event || typeof event !== "object") return false;
2643
+ const record = event as { type?: string; payload?: { sessionId?: string; status?: string } };
2644
+ return (
2645
+ record.type === "session.run.status.updated" &&
2646
+ record.payload?.sessionId === "main" &&
2647
+ record.payload?.status === "busy"
2648
+ );
2649
+ });
2650
+ expect(busyStatusEvent).toBeTruthy();
2651
+ });
2652
+
2653
+ test("emits session.run.error for non-timeout session errors", async () => {
2654
+ const events: Array<unknown> = [];
2655
+ const runtime = createRuntimeWithClient(
2656
+ createMockClient({
2657
+ prompt: async (request) => assistantResponse(request.path.id, "ok"),
2658
+ }),
2659
+ );
2660
+ runtime.subscribe(event => {
2661
+ events.push(event);
2662
+ });
2663
+
2664
+ await runtime.sendUserMessage({ sessionId: "main", content: "bootstrap" });
2665
+ events.length = 0;
2666
+
2667
+ const internal = runtime as unknown as {
2668
+ inFlightBackgroundChildRunCount: (_sessionId: string) => number;
2669
+ markBackgroundRunFailed: (_sessionId: string, _message: string) => void;
2670
+ handleSessionErrorEvent: (event: unknown) => void;
2671
+ };
2672
+ internal.inFlightBackgroundChildRunCount = () => 2;
2673
+ internal.markBackgroundRunFailed = () => {};
2674
+ internal.handleSessionErrorEvent({
2675
+ type: "session.error",
2676
+ properties: {
2677
+ sessionID: "ses-1",
2678
+ error: new Error("upstream disconnect"),
2679
+ },
2680
+ });
2681
+
2682
+ const runErrorEvent = events.find(event => {
2683
+ if (!event || typeof event !== "object") return false;
2684
+ const record = event as { type?: string; payload?: { sessionId?: string } };
2685
+ return record.type === "session.run.error" && record.payload?.sessionId === "main";
2686
+ });
2687
+ expect(runErrorEvent).toBeTruthy();
2688
+ });
2689
+
2690
+ test("queues parent message when child runs are in flight", async () => {
2691
+ const { getLaneQueue } = (await import("../queue/service")) as unknown as {
2692
+ getLaneQueue: () => { depth: (sessionId: string) => number; clearAll: () => void };
2693
+ };
2694
+
2695
+ let createCount = 0;
2696
+ let statusType: "busy" | "idle" = "busy";
2697
+ const runtime = createRuntimeWithClient(
2698
+ createMockClient({
2699
+ create: async () => {
2700
+ createCount += 1;
2701
+ return {
2702
+ data: {
2703
+ id: `ses-${createCount}`,
2704
+ title: createCount === 1 ? "main" : "background",
2705
+ },
2706
+ };
2707
+ },
2708
+ prompt: async (request) => assistantResponse(request.path.id, "OK"),
2709
+ promptAsync: async () => ({ data: undefined }),
2710
+ status: async () => ({
2711
+ data: {
2712
+ "ses-2": {
2713
+ type: statusType,
2714
+ },
2715
+ },
2716
+ }),
2717
+ }),
2718
+ );
2719
+
2720
+ const spawned = await runtime.spawnBackgroundSession({
2721
+ parentSessionId: "main",
2722
+ title: "child run",
2723
+ });
2724
+ await runtime.promptBackgroundAsync({
2725
+ runId: spawned.runId,
2726
+ content: "Investigate",
2727
+ });
2728
+ await runtime.getBackgroundStatus(spawned.runId);
2729
+
2730
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "follow up" })).rejects.toBeInstanceOf(
2731
+ RuntimeSessionQueuedError,
2732
+ );
2733
+ expect(getLaneQueue().depth("main")).toBe(1);
2734
+
2735
+ statusType = "idle";
2736
+ await runtime.getBackgroundStatus(spawned.runId);
2737
+ getLaneQueue().clearAll();
2738
+ });
2739
+
2740
+ test("queues non-heartbeat messages when session is busy", async () => {
2741
+ let promptResolve: () => void;
2742
+ const promptPromise = new Promise<void>((resolve) => {
2743
+ promptResolve = resolve;
2744
+ });
2745
+
2746
+ const runtime = createRuntimeWithClient(
2747
+ createMockClient({
2748
+ prompt: async () => {
2749
+ await promptPromise;
2750
+ return assistantResponse("ses-1", "OK");
2751
+ },
2752
+ }),
2753
+ );
2754
+
2755
+ const firstCall = runtime.sendUserMessage({ sessionId: "main", content: "first" });
2756
+
2757
+ await new Promise((resolve) => setTimeout(resolve, 10));
2758
+
2759
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "second" })).rejects.toBeInstanceOf(
2760
+ RuntimeSessionQueuedError,
2761
+ );
2762
+
2763
+ promptResolve!();
2764
+ await firstCall;
2765
+ });
2766
+
2767
+ test("does not enqueue heartbeat messages when session is busy", async () => {
2768
+ const { getLaneQueue } = (await import("../queue/service")) as unknown as {
2769
+ getLaneQueue: () => { depth: (sessionId: string) => number; clearAll: () => void };
2770
+ };
2771
+
2772
+ let promptResolve: () => void;
2773
+ const promptPromise = new Promise<void>((resolve) => {
2774
+ promptResolve = resolve;
2775
+ });
2776
+
2777
+ const runtime = createRuntimeWithClient(
2778
+ createMockClient({
2779
+ prompt: async () => {
2780
+ await promptPromise;
2781
+ return assistantResponse("ses-1", "OK");
2782
+ },
2783
+ }),
2784
+ );
2785
+
2786
+ const firstCall = runtime.sendUserMessage({ sessionId: "main", content: "first" });
2787
+
2788
+ await new Promise((resolve) => setTimeout(resolve, 10));
2789
+
2790
+ await expect(
2791
+ runtime.sendUserMessage({
2792
+ sessionId: "main",
2793
+ content: "heartbeat",
2794
+ metadata: { heartbeat: true },
2795
+ }),
2796
+ ).rejects.toBeInstanceOf(RuntimeSessionBusyError);
2797
+ expect(getLaneQueue().depth("main")).toBe(0);
2798
+
2799
+ promptResolve!();
2800
+ await firstCall;
2801
+ getLaneQueue().clearAll();
2802
+ });
2803
+
2804
+ test("keeps the active heartbeat session title pinned", async () => {
2805
+ const { patchHeartbeatRuntimeState } = (await import("../heartbeat/state")) as unknown as {
2806
+ patchHeartbeatRuntimeState: (patch: {
2807
+ sessionId?: string | null;
2808
+ backgroundRunId?: string | null;
2809
+ parentSessionId?: string | null;
2810
+ externalSessionId?: string | null;
2811
+ }) => void;
2812
+ };
2813
+
2814
+ const session = repository.createSession({
2815
+ title: "Heartbeat",
2816
+ model: "test-provider/test-model",
2817
+ });
2818
+ patchHeartbeatRuntimeState({
2819
+ sessionId: session.id,
2820
+ backgroundRunId: "bg-heartbeat-1",
2821
+ parentSessionId: "main",
2822
+ externalSessionId: "ses-heartbeat",
2823
+ });
2824
+
2825
+ const runtime = createRuntimeWithClient(
2826
+ createMockClient({
2827
+ get: async (request) => ({
2828
+ data: {
2829
+ id: (request as { path: { id: string } }).path.id,
2830
+ title: "Heartbeat prompt overview and action check",
2831
+ },
2832
+ }),
2833
+ prompt: async (request) => assistantResponse(request.path.id, "HEARTBEAT_OK"),
2834
+ }),
2835
+ );
2836
+
2837
+ await runtime.sendUserMessage({
2838
+ sessionId: session.id,
2839
+ content: "heartbeat",
2840
+ metadata: { heartbeat: true },
2841
+ });
2842
+
2843
+ expect(repository.getSessionById(session.id)?.title).toBe("Heartbeat");
2844
+ });
2845
+
2846
+ test("reports queued non-heartbeat messages while session is busy", async () => {
2847
+ const { getLaneQueue } = (await import("../queue/service")) as unknown as {
2848
+ getLaneQueue: () => { depth: (sessionId: string) => number; clearAll: () => void };
2849
+ };
2850
+
2851
+ let promptResolve: () => void;
2852
+ const promptPromise = new Promise<void>((resolve) => {
2853
+ promptResolve = resolve;
2854
+ });
2855
+
2856
+ const runtime = createRuntimeWithClient(
2857
+ createMockClient({
2858
+ prompt: async () => {
2859
+ await promptPromise;
2860
+ return assistantResponse("ses-1", "OK");
2861
+ },
2862
+ }),
2863
+ );
2864
+
2865
+ const firstCall = runtime.sendUserMessage({ sessionId: "main", content: "first" });
2866
+
2867
+ await new Promise((resolve) => setTimeout(resolve, 10));
2868
+
2869
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "second" })).rejects.toBeInstanceOf(
2870
+ RuntimeSessionQueuedError,
2871
+ );
2872
+ expect(getLaneQueue().depth("main")).toBe(1);
2873
+
2874
+ promptResolve!();
2875
+ await firstCall;
2876
+ getLaneQueue().clearAll();
2877
+ });
2878
+
2879
+ test("queues externally submitted messages while queue drain is active unless marked as internal drain", async () => {
2880
+ const runtime = createRuntimeWithClient(
2881
+ createMockClient({
2882
+ prompt: async () => assistantResponse("ses-1", "OK"),
2883
+ }),
2884
+ );
2885
+
2886
+ (runtime as unknown as { drainingSessions: Set<string> }).drainingSessions.add("main");
2887
+
2888
+ await expect(runtime.sendUserMessage({ sessionId: "main", content: "external" })).rejects.toBeInstanceOf(
2889
+ RuntimeSessionQueuedError,
2890
+ );
2891
+
2892
+ const ack = await runtime.sendUserMessage({
2893
+ sessionId: "main",
2894
+ content: "drained",
2895
+ metadata: { __queueDrain: true },
2896
+ });
2897
+ expect(ack.messages.some((message) => message.role === "assistant")).toBe(true);
2898
+ });
2899
+ });