@cortexkit/opencode-magic-context 0.43.1 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/README.md +18 -2
  2. package/dist/config/index.d.ts +13 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/project-security.d.ts +6 -3
  5. package/dist/config/project-security.d.ts.map +1 -1
  6. package/dist/config/schema/magic-context.d.ts +27 -0
  7. package/dist/config/schema/magic-context.d.ts.map +1 -1
  8. package/dist/{conflict-warning-hook-956fkedc.js → conflict-warning-hook-48f7np9d.js} +5 -5
  9. package/dist/{derive-budgets-b8ybssh9.js → derive-budgets-8kgevxmx.js} +2 -2
  10. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  11. package/dist/features/magic-context/compartment-embedding.d.ts +3 -2
  12. package/dist/features/magic-context/compartment-embedding.d.ts.map +1 -1
  13. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  14. package/dist/features/magic-context/dreamer/classify.d.ts +12 -0
  15. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/curate-category-rotation.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/curate-memory-safety.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/failed-invocation-evidence.d.ts +13 -0
  19. package/dist/features/magic-context/dreamer/failed-invocation-evidence.d.ts.map +1 -0
  20. package/dist/features/magic-context/dreamer/hidden-single-shot.d.ts.map +1 -1
  21. package/dist/features/magic-context/dreamer/lease.d.ts +5 -2
  22. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  24. package/dist/features/magic-context/dreamer/memory-claim-safety.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/module-apply.d.ts +1 -0
  26. package/dist/features/magic-context/dreamer/module-apply.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/retrospective-learnings.d.ts.map +1 -1
  28. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +1 -1
  29. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  30. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  31. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +2 -0
  32. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  33. package/dist/features/magic-context/dreamer/verify.d.ts +19 -0
  34. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  35. package/dist/features/magic-context/fail-closed-block.d.ts +7 -0
  36. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  37. package/dist/features/magic-context/index.d.ts +1 -0
  38. package/dist/features/magic-context/index.d.ts.map +1 -1
  39. package/dist/features/magic-context/install-instance-id.d.ts +33 -0
  40. package/dist/features/magic-context/install-instance-id.d.ts.map +1 -0
  41. package/dist/features/magic-context/memory/constants.d.ts.map +1 -1
  42. package/dist/features/magic-context/memory/embedding-backfill.d.ts.map +1 -1
  43. package/dist/features/magic-context/memory/single-store-embedding-drain.d.ts +35 -0
  44. package/dist/features/magic-context/memory/single-store-embedding-drain.d.ts.map +1 -0
  45. package/dist/features/magic-context/memory/verification-paths.d.ts.map +1 -1
  46. package/dist/features/magic-context/message-index.d.ts +23 -0
  47. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  48. package/dist/features/magic-context/migrations.d.ts +7 -0
  49. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  50. package/dist/features/magic-context/overflow-detection.d.ts +31 -8
  51. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  52. package/dist/features/magic-context/project-embedding-registry.d.ts +40 -1
  53. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  54. package/dist/features/magic-context/search.d.ts.map +1 -1
  55. package/dist/features/magic-context/session-decision-calibration.d.ts.map +1 -1
  56. package/dist/features/magic-context/storage-db.d.ts +1 -1
  57. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  58. package/dist/features/magic-context/storage-meta-persisted.d.ts +20 -5
  59. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  60. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  61. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  62. package/dist/features/magic-context/storage-notes.d.ts +1 -0
  63. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  64. package/dist/features/magic-context/storage-primers.d.ts.map +1 -1
  65. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  66. package/dist/features/magic-context/storage-unavailable-reason.d.ts +17 -0
  67. package/dist/features/magic-context/storage-unavailable-reason.d.ts.map +1 -0
  68. package/dist/features/magic-context/storage.d.ts +1 -1
  69. package/dist/features/magic-context/storage.d.ts.map +1 -1
  70. package/dist/features/magic-context/store-generation-rebase.d.ts +95 -2
  71. package/dist/features/magic-context/store-generation-rebase.d.ts.map +1 -1
  72. package/dist/features/magic-context/subagent-token-capture.d.ts.map +1 -1
  73. package/dist/features/magic-context/transform-decision-log.d.ts +1 -1
  74. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  75. package/dist/features/magic-context/types.d.ts +8 -3
  76. package/dist/features/magic-context/types.d.ts.map +1 -1
  77. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  78. package/dist/hooks/magic-context/apply-operations.d.ts +105 -9
  79. package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
  80. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  82. package/dist/hooks/magic-context/compartment-parser.d.ts +2 -0
  83. package/dist/hooks/magic-context/compartment-parser.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +1 -0
  85. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +6 -0
  87. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/compartment-runner-partial-recomp.d.ts.map +1 -1
  89. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/compartment-runner-types.d.ts +2 -0
  91. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  92. package/dist/hooks/magic-context/compartment-runner-validation.d.ts +7 -0
  93. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  94. package/dist/hooks/magic-context/context-limit-resolution.d.ts +31 -0
  95. package/dist/hooks/magic-context/context-limit-resolution.d.ts.map +1 -0
  96. package/dist/hooks/magic-context/ctx-reduce-availability.d.ts +60 -8
  97. package/dist/hooks/magic-context/ctx-reduce-availability.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/embed-history-runner.d.ts +0 -1
  99. package/dist/hooks/magic-context/embed-history-runner.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
  102. package/dist/hooks/magic-context/format-embed-status.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/heuristic-cleanup.d.ts.map +1 -1
  104. package/dist/hooks/magic-context/historian-host-runner.d.ts +233 -0
  105. package/dist/hooks/magic-context/historian-host-runner.d.ts.map +1 -0
  106. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/image-token-estimate.d.ts +9 -0
  108. package/dist/hooks/magic-context/image-token-estimate.d.ts.map +1 -1
  109. package/dist/hooks/magic-context/inject-compartments.d.ts +60 -1
  110. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/lkg-slot.d.ts +6 -0
  112. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/module-client.d.ts +21 -0
  114. package/dist/hooks/magic-context/module-client.d.ts.map +1 -0
  115. package/dist/hooks/magic-context/module-state-sync.d.ts +6 -2
  116. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  117. package/dist/hooks/magic-context/module-tool-backends.d.ts +47 -0
  118. package/dist/hooks/magic-context/module-tool-backends.d.ts.map +1 -0
  119. package/dist/hooks/magic-context/module-transport.d.ts +1 -1
  120. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  121. package/dist/hooks/magic-context/persist-filtered-noise.d.ts.map +1 -1
  122. package/dist/hooks/magic-context/read-session-chunk.d.ts +12 -0
  123. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  124. package/dist/hooks/magic-context/rust-mode-transform.d.ts +35 -0
  125. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  126. package/dist/hooks/magic-context/rust-refusal-recovery.d.ts +18 -0
  127. package/dist/hooks/magic-context/rust-refusal-recovery.d.ts.map +1 -1
  128. package/dist/hooks/magic-context/rust-served-m0.d.ts +20 -0
  129. package/dist/hooks/magic-context/rust-served-m0.d.ts.map +1 -0
  130. package/dist/hooks/magic-context/store-ahead-refusal.d.ts +40 -0
  131. package/dist/hooks/magic-context/store-ahead-refusal.d.ts.map +1 -0
  132. package/dist/hooks/magic-context/strip-content.d.ts +5 -5
  133. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  134. package/dist/hooks/magic-context/system-prompt-hash.d.ts +9 -0
  135. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  136. package/dist/hooks/magic-context/tag-messages.d.ts +19 -0
  137. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  138. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  139. package/dist/hooks/magic-context/tool-drop-target.d.ts +14 -0
  140. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  141. package/dist/hooks/magic-context/tool-input-size.d.ts +21 -0
  142. package/dist/hooks/magic-context/tool-input-size.d.ts.map +1 -0
  143. package/dist/hooks/magic-context/tool-sweep-policy.d.ts.map +1 -1
  144. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +124 -11
  145. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  146. package/dist/hooks/magic-context/transform.d.ts +13 -0
  147. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  148. package/dist/{index-0ccmyzbj.js → index-01ft0h3p.js} +1 -1
  149. package/dist/{index-j7cnyvq1.js → index-2zf5fv89.js} +254 -61
  150. package/dist/{index-kpmmr8s0.js → index-5f4qsnaq.js} +2 -2
  151. package/dist/{index-yc81txp4.js → index-c906f3ff.js} +4 -4
  152. package/dist/{index-1gtwrdc4.js → index-cj5qe2nw.js} +3 -3
  153. package/dist/{index-febg06dy.js → index-dx0xnd4q.js} +1 -1
  154. package/dist/{index-v8qr6tt2.js → index-pye48m79.js} +3 -3
  155. package/dist/{index-7wpwr2e2.js → index-rdey8znw.js} +1175 -290
  156. package/dist/{index-wgzyeyf5.js → index-tjq5jztd.js} +22 -6
  157. package/dist/{index-9r1cbh9e.js → index-v6wsttqt.js} +1 -1
  158. package/dist/index.d.ts.map +1 -1
  159. package/dist/index.js +3546 -1642
  160. package/dist/{message-time-backfill-0ccvyk9n.js → message-time-backfill-rm9wmcem.js} +3 -3
  161. package/dist/plugin/dream-timer.d.ts +2 -0
  162. package/dist/plugin/dream-timer.d.ts.map +1 -1
  163. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  164. package/dist/plugin/messages-transform.d.ts.map +1 -1
  165. package/dist/plugin/native-compaction-guard.d.ts +22 -0
  166. package/dist/plugin/native-compaction-guard.d.ts.map +1 -0
  167. package/dist/plugin/rpc-handlers.d.ts +11 -0
  168. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  169. package/dist/{read-session-chunk-z7fv0qjv.js → read-session-chunk-ktz5zt70.js} +5 -3
  170. package/dist/{recomp-orchestrator-r00aa5t3.js → recomp-orchestrator-ahekzt0s.js} +8 -8
  171. package/dist/{safe-notification-target-vrkjrbgb.js → safe-notification-target-6w0esgd6.js} +2 -2
  172. package/dist/{send-session-notification-3warjyjp.js → send-session-notification-w7xbnrsm.js} +4 -4
  173. package/dist/shared/index.d.ts +1 -0
  174. package/dist/shared/index.d.ts.map +1 -1
  175. package/dist/shared/logger.d.ts.map +1 -1
  176. package/dist/shared/model-suggestion-retry.d.ts +13 -1
  177. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  178. package/dist/shared/opencode-db-path.d.ts.map +1 -1
  179. package/dist/shared/prompt-async-transport.d.ts +25 -0
  180. package/dist/shared/prompt-async-transport.d.ts.map +1 -0
  181. package/dist/shared/rpc-server.d.ts +1 -0
  182. package/dist/shared/rpc-server.d.ts.map +1 -1
  183. package/dist/shared/rpc-types.d.ts +16 -0
  184. package/dist/shared/rpc-types.d.ts.map +1 -1
  185. package/dist/shared/rpc-utils.d.ts +3 -0
  186. package/dist/shared/rpc-utils.d.ts.map +1 -1
  187. package/dist/shared/sqlite-write-locker-test-support.d.ts +7 -0
  188. package/dist/shared/sqlite-write-locker-test-support.d.ts.map +1 -0
  189. package/dist/shared/status-summary.d.ts +9 -1
  190. package/dist/shared/status-summary.d.ts.map +1 -1
  191. package/dist/shared/tag-transcript.d.ts.map +1 -1
  192. package/dist/shared/test-fake-executable.d.ts +38 -0
  193. package/dist/shared/test-fake-executable.d.ts.map +1 -0
  194. package/dist/shared/user-facing-codes.d.ts +20 -5
  195. package/dist/shared/user-facing-codes.d.ts.map +1 -1
  196. package/dist/shared/window-geometry.d.ts +6 -3
  197. package/dist/shared/window-geometry.d.ts.map +1 -1
  198. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  199. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  200. package/dist/tools/ctx-reduce/tools.d.ts.map +1 -1
  201. package/dist/tools/ctx-search/tools.d.ts.map +1 -1
  202. package/dist/v2/fold/boundary.d.ts +61 -0
  203. package/dist/v2/fold/boundary.d.ts.map +1 -0
  204. package/dist/v2/fold/restore.d.ts.map +1 -1
  205. package/dist/v2/hidden-completion.d.ts +16 -3
  206. package/dist/v2/hidden-completion.d.ts.map +1 -1
  207. package/dist/v2/hooks/context.d.ts +45 -14
  208. package/dist/v2/hooks/context.d.ts.map +1 -1
  209. package/dist/v2/hooks/hidden-child.d.ts +6 -0
  210. package/dist/v2/hooks/hidden-child.d.ts.map +1 -1
  211. package/dist/v2/hooks/payload.d.ts.map +1 -1
  212. package/dist/v2/hooks/refusal.d.ts +9 -1
  213. package/dist/v2/hooks/refusal.d.ts.map +1 -1
  214. package/dist/v2/hooks/restore-rows.d.ts +29 -0
  215. package/dist/v2/hooks/restore-rows.d.ts.map +1 -0
  216. package/dist/v2/hooks/rpc-live-state.d.ts +1 -1
  217. package/dist/v2/hooks/rpc-live-state.d.ts.map +1 -1
  218. package/dist/v2/hooks/rust-mode.d.ts +47 -0
  219. package/dist/v2/hooks/rust-mode.d.ts.map +1 -0
  220. package/dist/v2/hooks/session-project-backfill.d.ts +15 -0
  221. package/dist/v2/hooks/session-project-backfill.d.ts.map +1 -0
  222. package/dist/v2/hooks/storage-gate.d.ts +42 -0
  223. package/dist/v2/hooks/storage-gate.d.ts.map +1 -0
  224. package/dist/v2/hooks/storage-notice.d.ts +29 -0
  225. package/dist/v2/hooks/storage-notice.d.ts.map +1 -0
  226. package/dist/v2/hooks/store.d.ts +16 -0
  227. package/dist/v2/hooks/store.d.ts.map +1 -1
  228. package/dist/v2/hooks/tools.d.ts +11 -2
  229. package/dist/v2/hooks/tools.d.ts.map +1 -1
  230. package/dist/v2/hooks/types.d.ts +4 -0
  231. package/dist/v2/hooks/types.d.ts.map +1 -1
  232. package/dist/v2/hooks/update-check.d.ts +8 -0
  233. package/dist/v2/hooks/update-check.d.ts.map +1 -1
  234. package/dist/v2/hooks/usage-persist.d.ts +31 -0
  235. package/dist/v2/hooks/usage-persist.d.ts.map +1 -0
  236. package/dist/v2/server.js +12692 -8030
  237. package/dist/v2/store-reader.d.ts +17 -0
  238. package/dist/v2/store-reader.d.ts.map +1 -1
  239. package/dist/wrapup-orchestrator-9srk0kjf.js +18 -0
  240. package/package.json +1 -1
  241. package/src/agents/magic-context-prompt.ts +3 -3
  242. package/src/config/index.ts +40 -0
  243. package/src/config/misplaced-agent-model.test.ts +61 -0
  244. package/src/config/project-security.test.ts +17 -0
  245. package/src/config/project-security.ts +28 -4
  246. package/src/config/schema/magic-context.ts +29 -2
  247. package/src/shared/index.ts +1 -0
  248. package/src/shared/logger.test.ts +63 -2
  249. package/src/shared/logger.ts +23 -5
  250. package/src/shared/model-suggestion-retry.test.ts +115 -4
  251. package/src/shared/model-suggestion-retry.ts +57 -12
  252. package/src/shared/opencode-db-path.test.ts +18 -0
  253. package/src/shared/opencode-db-path.ts +5 -2
  254. package/src/shared/prompt-async-transport.test.ts +217 -0
  255. package/src/shared/prompt-async-transport.ts +242 -0
  256. package/src/shared/prompt-surface-a1-golden.md +8 -8
  257. package/src/shared/rpc-server.ts +13 -1
  258. package/src/shared/rpc-types.ts +17 -0
  259. package/src/shared/rpc-utils.ts +28 -0
  260. package/src/shared/sqlite-transaction-mode-guard.test.ts +198 -0
  261. package/src/shared/sqlite-write-locker-test-support.ts +62 -0
  262. package/src/shared/status-detail-text.test.ts +23 -0
  263. package/src/shared/status-summary.ts +25 -1
  264. package/src/shared/tag-transcript.ts +82 -19
  265. package/src/shared/test-fake-executable.test.ts +54 -0
  266. package/src/shared/test-fake-executable.ts +110 -0
  267. package/src/shared/user-facing-codes.ts +23 -5
  268. package/src/shared/window-geometry.ts +6 -3
  269. package/dist/v2/hooks/provider-admission.d.ts +0 -22
  270. package/dist/v2/hooks/provider-admission.d.ts.map +0 -1
  271. package/dist/wrapup-orchestrator-y0bs7t5a.js +0 -18
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { HiddenAgentStepLimit } from "../v2/hooks/hidden-child";
3
4
  import {
5
+ getPromptFailureDetail,
4
6
  promptSyncWithModelSuggestionRetry,
5
7
  promptSyncWithValidatedOutputRetry,
6
8
  } from "./model-suggestion-retry";
@@ -158,6 +160,18 @@ describe("promptSyncWithModelSuggestionRetry", () => {
158
160
  expect(prompt).toHaveBeenCalledTimes(0);
159
161
  });
160
162
 
163
+ test("pre-aborted lease signal carries its loss reason into the prompt error", async () => {
164
+ const controller = new AbortController();
165
+ controller.abort(new Error("lease_lost: taken by holder-b"));
166
+ const prompt = mock(async () => {});
167
+ await expect(
168
+ promptSyncWithModelSuggestionRetry(createClient(prompt), createArgs(), {
169
+ signal: controller.signal,
170
+ }),
171
+ ).rejects.toThrow("prompt aborted by external signal: lease_lost: taken by holder-b");
172
+ expect(prompt).not.toHaveBeenCalled();
173
+ });
174
+
161
175
  test("AbortError name short-circuits", async () => {
162
176
  const abortError = new Error("aborted by provider");
163
177
  abortError.name = "AbortError";
@@ -280,10 +294,10 @@ describe("promptSyncWithModelSuggestionRetry", () => {
280
294
  const abort = mock(async () => ({}));
281
295
  const client = createClient(prompt as never, abort);
282
296
 
283
- setTimeout(() => controller.abort(), 10);
297
+ setTimeout(() => controller.abort(new Error("lease_lost: taken by holder-b")), 10);
284
298
  await expect(
285
299
  promptSyncWithModelSuggestionRetry(client, createArgs(), { signal: controller.signal }),
286
- ).rejects.toThrow(/aborted by external signal/);
300
+ ).rejects.toThrow("prompt aborted by external signal: lease_lost: taken by holder-b");
287
301
  expect(abort).toHaveBeenCalledTimes(1);
288
302
  expect((abort.mock.calls[0]?.[0] as { path: { id: string } }).path.id).toBe("ses-test");
289
303
  });
@@ -393,6 +407,28 @@ describe("promptSyncWithModelSuggestionRetry", () => {
393
407
  });
394
408
 
395
409
  describe("promptSyncWithValidatedOutputRetry", () => {
410
+ test("records a step cap as step_limit without a provider error", async () => {
411
+ const prompt = mock(async () => {
412
+ throw new HiddenAgentStepLimit("dreamer-retrospective", 40);
413
+ });
414
+ const client = createClient(prompt);
415
+ let caught: unknown;
416
+ try {
417
+ await promptSyncWithValidatedOutputRetry(client, createArgs(), {
418
+ fetchOutput: async () => "unused",
419
+ validateOutput: (output) => output,
420
+ });
421
+ } catch (error) {
422
+ caught = error;
423
+ }
424
+ expect(caught).toBeInstanceOf(HiddenAgentStepLimit);
425
+ expect(getPromptFailureDetail(caught)).toMatchObject({
426
+ failureClass: "step_limit",
427
+ providerError: null,
428
+ });
429
+ expect(prompt).toHaveBeenCalledTimes(1);
430
+ });
431
+
396
432
  test("surfaces a host-recorded assistant refusal even when the row has no text", async () => {
397
433
  const client = createClient(mock(async () => ({})));
398
434
  const refusal =
@@ -517,7 +553,7 @@ describe("promptSyncWithValidatedOutputRetry", () => {
517
553
  });
518
554
  });
519
555
 
520
- test("all empty outputs surface the original validation failure", async () => {
556
+ test("all empty outputs surface the last validation failure and every attempted model", async () => {
521
557
  const prompt = mock(async () => ({}));
522
558
  const messages = mock(async () => "");
523
559
  const client = createClient(prompt, undefined, messages);
@@ -533,9 +569,84 @@ describe("promptSyncWithValidatedOutputRetry", () => {
533
569
  return output.trim();
534
570
  },
535
571
  }),
536
- ).rejects.toThrow("empty output from primary");
572
+ ).rejects.toThrow(
573
+ /All models exhausted \(primary, anthropic\/claude-sonnet-4-6\): empty output from anthropic\/claude-sonnet-4-6/,
574
+ );
537
575
 
538
576
  expect(prompt).toHaveBeenCalledTimes(2);
539
577
  expect(messages).toHaveBeenCalledTimes(2);
540
578
  });
541
579
  });
580
+
581
+ /**
582
+ * Bun's fetch rejects with this exact object when its default request timer
583
+ * fires on a plugin SDK request (the name is "TimeoutError", legacy code 23).
584
+ * The dreamer ledger shows it as `TimeoutError message="The operation timed out." code=23`.
585
+ */
586
+ function hostFetchTimeout(): Error {
587
+ return new DOMException("The operation timed out.", "TimeoutError") as unknown as Error;
588
+ }
589
+
590
+ describe("host fetch TimeoutError is a timeout", () => {
591
+ test("validated retry stops the chain, aborts the child, and reports provider_timeout", async () => {
592
+ const prompt = mock(async () => {
593
+ throw hostFetchTimeout();
594
+ });
595
+ const abort = mock(async () => ({}));
596
+ const client = createClient(prompt, abort);
597
+
598
+ let caught: unknown;
599
+ try {
600
+ await promptSyncWithValidatedOutputRetry(client, createArgs(), {
601
+ fallbackModels: ["anthropic/claude-sonnet-4-6", "google/gemini-3-flash"],
602
+ fetchOutput: async () => "unused",
603
+ validateOutput: (output: string) => output,
604
+ });
605
+ } catch (error) {
606
+ caught = error;
607
+ }
608
+
609
+ expect((caught as Error | undefined)?.name).toBe("TimeoutError");
610
+ // Another model would be sent into the same still-running child session and
611
+ // hit the same host timer, so the chain must stop at the first attempt.
612
+ expect(prompt).toHaveBeenCalledTimes(1);
613
+ expect(getPromptFailureDetail(caught)?.failureClass).toBe("provider_timeout");
614
+ // The host loop keeps running after the client-side timer fires unless the
615
+ // child is aborted explicitly.
616
+ expect(abort).toHaveBeenCalledTimes(1);
617
+ expect(abort.mock.calls[0]?.[0]).toEqual({ path: { id: "ses-test" } });
618
+ });
619
+
620
+ test("unvalidated retry stops the chain and aborts the child", async () => {
621
+ const prompt = mock(async () => {
622
+ throw hostFetchTimeout();
623
+ });
624
+ const abort = mock(async () => ({}));
625
+ const client = createClient(prompt, abort);
626
+
627
+ await expect(
628
+ promptSyncWithModelSuggestionRetry(client, createArgs(), {
629
+ fallbackModels: ["anthropic/claude-sonnet-4-6"],
630
+ }),
631
+ ).rejects.toThrow("The operation timed out.");
632
+
633
+ expect(prompt).toHaveBeenCalledTimes(1);
634
+ expect(abort).toHaveBeenCalledTimes(1);
635
+ });
636
+
637
+ test("an ordinary provider error still falls back and does not abort the child", async () => {
638
+ const prompt = mock(async () => {
639
+ if (prompt.mock.calls.length === 1) throw new Error("upstream 502");
640
+ return {};
641
+ });
642
+ const abort = mock(async () => ({}));
643
+ const client = createClient(prompt, abort);
644
+
645
+ await promptSyncWithModelSuggestionRetry(client, createArgs(), {
646
+ fallbackModels: ["anthropic/claude-sonnet-4-6"],
647
+ });
648
+
649
+ expect(prompt).toHaveBeenCalledTimes(2);
650
+ expect(abort).not.toHaveBeenCalled();
651
+ });
652
+ });
@@ -1,6 +1,7 @@
1
1
  import type { createOpencodeClient } from "@opencode-ai/sdk";
2
2
 
3
3
  import { detectOverflow } from "../features/magic-context/overflow-detection";
4
+ import { HiddenAgentStepLimit } from "../v2/hooks/hidden-child";
4
5
  import {
5
6
  extractLatestAssistantFailure,
6
7
  extractLatestAssistantText,
@@ -113,6 +114,7 @@ export interface ValidatedPromptRetryResult<TOutput, TValidated> {
113
114
  export type PromptFailureClass =
114
115
  | "provider_timeout"
115
116
  | "provider_error"
117
+ | "step_limit"
116
118
  | "empty_completion"
117
119
  | "no_models"
118
120
  | "child_aborted"
@@ -131,6 +133,29 @@ export interface PromptFailureDetail {
131
133
 
132
134
  const promptFailureDetails = new WeakMap<object, PromptFailureDetail>();
133
135
 
136
+ /**
137
+ * True for a request timer that fired inside the host client rather than ours.
138
+ * Bun's fetch rejects with a DOMException named "TimeoutError" ("The operation
139
+ * timed out.", legacy code 23) when its default per-request timer expires, and
140
+ * `AbortSignal.timeout` produces the same shape. Some OpenCode 1 builds hand
141
+ * plugins an SDK client whose fetch keeps Bun's default timer (about five to six
142
+ * minutes), so a synchronous `session.prompt` that stays open for a whole agent
143
+ * loop fails this way while the host keeps running the child session.
144
+ */
145
+ export function isHostTimeoutError(error: unknown): boolean {
146
+ return (
147
+ error !== null &&
148
+ typeof error === "object" &&
149
+ (error as { name?: unknown }).name === "TimeoutError"
150
+ );
151
+ }
152
+
153
+ /** Our own slice expiry (see `promptWithTimeout`) or a host request timer. */
154
+ export function isPromptTimeoutError(error: unknown): boolean {
155
+ if (isHostTimeoutError(error)) return true;
156
+ return error instanceof Error && /^prompt timed out after \d+ms$/.test(error.message);
157
+ }
158
+
134
159
  export function getPromptFailureDetail(error: unknown): PromptFailureDetail | null {
135
160
  return error !== null && typeof error === "object"
136
161
  ? (promptFailureDetails.get(error) ?? null)
@@ -204,6 +229,11 @@ export function parseModelSuggestion(error: unknown): ModelSuggestionInfo | null
204
229
  };
205
230
  }
206
231
 
232
+ function externalAbortMessage(signal: AbortSignal): string {
233
+ const reason = signal.reason;
234
+ return `prompt aborted by external signal${reason instanceof Error && /^lease_(?:lost|expired):/.test(reason.message) ? `: ${reason.message}` : ""}`;
235
+ }
236
+
207
237
  async function promptWithTimeout(
208
238
  client: Client,
209
239
  args: PromptArgs,
@@ -218,7 +248,7 @@ async function promptWithTimeout(
218
248
  // and avoids one wasted upstream `client.session.prompt` round-trip
219
249
  // before `isNonRetryable` catches the cancellation at the chain loop.
220
250
  if (signal?.aborted) {
221
- throw new Error("prompt aborted by external signal");
251
+ throw new Error(externalAbortMessage(signal));
222
252
  }
223
253
  const controller = new AbortController();
224
254
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -242,7 +272,7 @@ async function promptWithTimeout(
242
272
  if (!transport || transport.childSessionId) {
243
273
  await abortChildRun(client, transport?.childSessionId ?? args.path.id);
244
274
  }
245
- throw new Error("prompt aborted by external signal");
275
+ throw new Error(externalAbortMessage(signal));
246
276
  }
247
277
  if (controller.signal.aborted) {
248
278
  // Our timeout fired. Same problem: abort the server-side run loop, not
@@ -253,6 +283,15 @@ async function promptWithTimeout(
253
283
  }
254
284
  throw new Error(`prompt timed out after ${timeoutMs}ms`);
255
285
  }
286
+ if (isHostTimeoutError(error)) {
287
+ // The host client's own request timer fired. Only our side of the
288
+ // request ended: the child's run loop is still going on the server and
289
+ // would keep calling the model, so stop it the same way as above. The
290
+ // original error is kept so the ledger still shows which timer fired.
291
+ if (!transport || transport.childSessionId) {
292
+ await abortChildRun(client, transport?.childSessionId ?? args.path.id);
293
+ }
294
+ }
256
295
  throw error;
257
296
  } finally {
258
297
  clearTimeout(timeout);
@@ -266,7 +305,7 @@ async function promptWithTimeout(
266
305
  }
267
306
  throw new Error(
268
307
  signal?.aborted
269
- ? "prompt aborted by external signal"
308
+ ? externalAbortMessage(signal)
270
309
  : `prompt timed out after ${timeoutMs}ms`,
271
310
  );
272
311
  }
@@ -312,7 +351,7 @@ async function abortChildRun(client: Client, sessionId: string): Promise<void> {
312
351
  * different model.
313
352
  */
314
353
  function isNonRetryable(error: unknown, externalSignal?: AbortSignal): boolean {
315
- if (externalSignal?.aborted) return true;
354
+ if (externalSignal?.aborted || error instanceof HiddenAgentStepLimit) return true;
316
355
 
317
356
  if (error instanceof Error) {
318
357
  if (error.name === "AbortError") return true;
@@ -321,6 +360,9 @@ function isNonRetryable(error: unknown, externalSignal?: AbortSignal): boolean {
321
360
  if (error.message === "prompt aborted by external signal") return true;
322
361
  if (/^prompt timed out after \d+ms$/.test(error.message)) return true;
323
362
  }
363
+ // A host request timer is a timeout too. Retrying would send the next model
364
+ // into the same child session, where it meets the same timer.
365
+ if (isHostTimeoutError(error)) return true;
324
366
 
325
367
  if (detectOverflow(error).isOverflow) return true;
326
368
 
@@ -350,10 +392,13 @@ function classifyPromptFailure(
350
392
  externalSignal?: AbortSignal,
351
393
  ): PromptFailureClass {
352
394
  const message = extractMessage(error);
395
+ if (error instanceof HiddenAgentStepLimit) return "step_limit";
353
396
  if (externalSignal?.aborted || message === "prompt aborted by external signal") {
354
397
  return "child_aborted";
355
398
  }
356
- if (/^prompt timed out after \d+ms$/.test(message)) return "provider_timeout";
399
+ if (/^prompt timed out after \d+ms$/.test(message) || isHostTimeoutError(error)) {
400
+ return "provider_timeout";
401
+ }
357
402
  if (phase === "validation") {
358
403
  if (/returned no (?:assistant )?output|no assistant output/i.test(message)) {
359
404
  return "empty_completion";
@@ -764,11 +809,11 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
764
809
  log(
765
810
  `[${callContext}] all models exhausted; tried: ${failedAttempts.map((failure) => failure.attempt.label).join(", ")}; original error: ${shortErr(firstError)}; last error: ${shortErr(lastError)}`,
766
811
  );
767
- throwWithPromptFailure(
768
- firstError ?? lastError ?? new Error("All fallback models failed validation"),
769
- failedAttempts,
770
- args,
771
- timeoutMs,
772
- options.transport,
773
- );
812
+ const cause = lastError ?? firstError;
813
+ const exhausted =
814
+ cause instanceof Error
815
+ ? cause
816
+ : new Error(String(cause ?? "All fallback models failed validation"));
817
+ exhausted.message = `All models exhausted (${failedAttempts.map((failure) => failure.attempt.label).join(", ")}): ${exhausted.message}`;
818
+ throwWithPromptFailure(exhausted, failedAttempts, args, timeoutMs, options.transport);
774
819
  }
@@ -197,6 +197,24 @@ describe("resolveOpenCodeDbPath", () => {
197
197
  });
198
198
  });
199
199
 
200
+ it("takes an absolute v2 OPENCODE_DB as is and resolves a relative one against the data dir", () => {
201
+ // OpenCode 2 resolves the database with `path.resolve(data, OPENCODE_DB)`;
202
+ // verified against the 2.0.15 binary's `opencode debug paths db`.
203
+ const { dataHome, openCodeDir } = useDataHome();
204
+ const absolute = join(dataHome, "elsewhere", "custom.db");
205
+ expect(resolveOpenCodeDbPath("v2", { dataHome, env: { OPENCODE_DB: absolute } })).toEqual({
206
+ path: absolute,
207
+ source: "OPENCODE_DB",
208
+ channel: null,
209
+ });
210
+ expect(
211
+ resolveOpenCodeDbPath("v2", { dataHome, env: { OPENCODE_DB: "nested/../rel.db" } }),
212
+ ).toEqual({ path: join(openCodeDir, "rel.db"), source: "OPENCODE_DB", channel: null });
213
+ expect(resolveOpenCodeDbPath("v2", { dataHome, env: { OPENCODE_DB: ":memory:" } })).toEqual(
214
+ { path: ":memory:", source: "OPENCODE_DB", channel: null },
215
+ );
216
+ });
217
+
200
218
  it("detects store generations and refuses a mismatched schema before reading", () => {
201
219
  const { openCodeDir } = useDataHome();
202
220
  const v1Path = join(openCodeDir, "v1.db");
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { isAbsolute, join } from "node:path";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
4
 
5
5
  export type OpenCodeDbPathSource = "OPENCODE_DB" | "channel" | "default" | "discovered";
6
6
  export type OpenCodeHostGeneration = "v1" | "v2";
@@ -185,7 +185,10 @@ function resolveV2Fresh(
185
185
  const filename = sourceOpenCodeDatabaseFilename("v2", channel, env);
186
186
  const explicit = env.OPENCODE_DB !== undefined;
187
187
  return {
188
- path: filename === ":memory:" ? filename : join(dataDir, filename),
188
+ // OpenCode 2 uses `path.resolve(data, filename)`: an absolute
189
+ // OPENCODE_DB is taken as is and a relative one is resolved against
190
+ // the data directory. `join` would nest an absolute value under it.
191
+ path: filename === ":memory:" ? filename : resolve(dataDir, filename),
189
192
  source: explicit ? "OPENCODE_DB" : env.OPENCODE_CHANNEL ? "channel" : "default",
190
193
  channel: explicit ? null : channel,
191
194
  };
@@ -0,0 +1,217 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ import { promptSyncWithValidatedOutputRetry } from "./model-suggestion-retry";
4
+ import {
5
+ createPromptAsyncTransport,
6
+ promptAsyncAndWaitForIdle,
7
+ supportsPromptAsync,
8
+ } from "./prompt-async-transport";
9
+
10
+ type Message = {
11
+ info: {
12
+ id: string;
13
+ role: string;
14
+ time: { created: number; completed?: number };
15
+ finish?: string;
16
+ error?: unknown;
17
+ };
18
+ parts: Array<{ type: string; text?: string }>;
19
+ };
20
+
21
+ /**
22
+ * A host double for one child session. `plan` describes what each status poll
23
+ * after the prompt sees: a busy tick, or the moment the run settles with a
24
+ * final assistant message.
25
+ */
26
+ function asyncHost(options: {
27
+ plan: Array<"busy" | "idle-pending" | "settle">;
28
+ initialMessages?: Message[];
29
+ answer?: string;
30
+ }) {
31
+ const messages: Message[] = [...(options.initialMessages ?? [])];
32
+ let polls = 0;
33
+ let prompted = false;
34
+ const promptAsync = mock(async (_request: unknown) => {
35
+ prompted = true;
36
+ messages.push({
37
+ info: { id: `msg_user_${messages.length}`, role: "user", time: { created: 1 } },
38
+ parts: [{ type: "text", text: "go" }],
39
+ });
40
+ return { data: undefined };
41
+ });
42
+ const prompt = mock(async () => ({}));
43
+ const abort = mock(async () => ({ data: true }));
44
+ const status = mock(async () => {
45
+ if (!prompted) return { data: {} };
46
+ const step = options.plan[Math.min(polls, options.plan.length - 1)];
47
+ polls += 1;
48
+ if (step === "busy") {
49
+ // An intermediate tool-call step is already complete while the loop runs.
50
+ messages.push({
51
+ info: {
52
+ id: `msg_step_${messages.length}`,
53
+ role: "assistant",
54
+ time: { created: 2, completed: 3 },
55
+ finish: "tool-calls",
56
+ },
57
+ parts: [{ type: "tool" }],
58
+ });
59
+ return { data: { "ses-child": { type: "busy" } } };
60
+ }
61
+ if (step === "settle" && messages.at(-1)?.info.finish !== "stop") {
62
+ messages.push({
63
+ info: {
64
+ id: `msg_final_${messages.length}`,
65
+ role: "assistant",
66
+ time: { created: 4, completed: 5 },
67
+ finish: "stop",
68
+ },
69
+ parts: [{ type: "text", text: options.answer ?? "<done/>" }],
70
+ });
71
+ }
72
+ return { data: {} };
73
+ });
74
+ const list = mock(async () => ({ data: [...messages] }));
75
+ return {
76
+ client: { session: { promptAsync, prompt, abort, status, messages: list } } as never,
77
+ promptAsync,
78
+ prompt,
79
+ abort,
80
+ status,
81
+ list,
82
+ polls: () => polls,
83
+ };
84
+ }
85
+
86
+ function request(signal?: AbortSignal) {
87
+ return {
88
+ path: { id: "ses-child" },
89
+ query: { directory: "/repo" },
90
+ body: { parts: [{ type: "text", text: "go" }] },
91
+ ...(signal ? { signal } : {}),
92
+ };
93
+ }
94
+
95
+ describe("supportsPromptAsync", () => {
96
+ test("requires both prompt_async and session status", () => {
97
+ expect(supportsPromptAsync(asyncHost({ plan: ["settle"] }).client)).toBe(true);
98
+ expect(supportsPromptAsync({ session: { prompt: async () => ({}) } } as never)).toBe(false);
99
+ expect(supportsPromptAsync(undefined)).toBe(false);
100
+ expect(createPromptAsyncTransport({ session: {} } as never, "ses-child")).toBeUndefined();
101
+ });
102
+ });
103
+
104
+ describe("promptAsyncAndWaitForIdle", () => {
105
+ test("returns only after the child is idle with a settled final assistant message", async () => {
106
+ const host = asyncHost({ plan: ["busy", "busy", "busy", "settle"] });
107
+
108
+ await promptAsyncAndWaitForIdle(host.client, request(), { pollIntervalMs: 1 });
109
+
110
+ expect(host.promptAsync).toHaveBeenCalledTimes(1);
111
+ expect(host.prompt).not.toHaveBeenCalled();
112
+ // Completed tool-call steps during busy polls must not end the wait early.
113
+ expect(host.polls()).toBe(4);
114
+ expect(host.promptAsync.mock.calls[0]?.[0]).toMatchObject({
115
+ path: { id: "ses-child" },
116
+ query: { directory: "/repo" },
117
+ });
118
+ });
119
+
120
+ test("keeps waiting through an idle gap before the run starts", async () => {
121
+ const host = asyncHost({ plan: ["idle-pending", "idle-pending", "busy", "settle"] });
122
+
123
+ await promptAsyncAndWaitForIdle(host.client, request(), {
124
+ pollIntervalMs: 1,
125
+ startGraceMs: 60_000,
126
+ });
127
+
128
+ expect(host.polls()).toBe(4);
129
+ });
130
+
131
+ test("ignores an assistant answer left by an earlier attempt in the same child", async () => {
132
+ const earlier: Message = {
133
+ info: {
134
+ id: "msg_old",
135
+ role: "assistant",
136
+ time: { created: 0, completed: 0 },
137
+ finish: "stop",
138
+ },
139
+ parts: [{ type: "text", text: "stale" }],
140
+ };
141
+ const host = asyncHost({
142
+ plan: ["idle-pending", "busy", "settle"],
143
+ initialMessages: [earlier],
144
+ });
145
+
146
+ await promptAsyncAndWaitForIdle(host.client, request(), {
147
+ pollIntervalMs: 1,
148
+ startGraceMs: 60_000,
149
+ });
150
+
151
+ expect(host.polls()).toBe(3);
152
+ });
153
+
154
+ test("fails when no run starts within the start grace", async () => {
155
+ const host = asyncHost({ plan: ["idle-pending"] });
156
+ host.promptAsync.mockImplementation(async () => ({ data: undefined }));
157
+
158
+ await expect(
159
+ promptAsyncAndWaitForIdle(host.client, request(), {
160
+ pollIntervalMs: 1,
161
+ startGraceMs: 20,
162
+ }),
163
+ ).rejects.toThrow("did not start a run");
164
+ });
165
+
166
+ test("surfaces a rejected prompt_async request", async () => {
167
+ const host = asyncHost({ plan: ["settle"] });
168
+ host.promptAsync.mockImplementation(async () => ({
169
+ data: undefined,
170
+ error: { name: "BadRequest", data: { message: "unknown agent" } },
171
+ }));
172
+
173
+ await expect(
174
+ promptAsyncAndWaitForIdle(host.client, request(), { pollIntervalMs: 1 }),
175
+ ).rejects.toThrow("prompt_async was rejected");
176
+ });
177
+
178
+ test("stops waiting as soon as the caller's signal aborts", async () => {
179
+ const host = asyncHost({ plan: ["busy"] });
180
+ const controller = new AbortController();
181
+ setTimeout(() => controller.abort(), 20);
182
+
183
+ await expect(
184
+ promptAsyncAndWaitForIdle(host.client, request(controller.signal), {
185
+ pollIntervalMs: 5,
186
+ }),
187
+ ).rejects.toBeDefined();
188
+ });
189
+ });
190
+
191
+ describe("prompt_async transport under the retry chain", () => {
192
+ test("our slice stays the only timer and aborts the busy child when it expires", async () => {
193
+ const host = asyncHost({ plan: ["busy"] });
194
+ const transport = createPromptAsyncTransport(host.client, "ses-child", {
195
+ pollIntervalMs: 5,
196
+ });
197
+ expect(transport).toBeDefined();
198
+
199
+ let caught: unknown;
200
+ try {
201
+ await promptSyncWithValidatedOutputRetry(host.client, request(), {
202
+ transport,
203
+ timeoutMs: 60,
204
+ fallbackModels: ["anthropic/claude-sonnet-4-6"],
205
+ fetchOutput: async () => "unused",
206
+ validateOutput: (output: string) => output,
207
+ });
208
+ } catch (error) {
209
+ caught = error;
210
+ }
211
+
212
+ expect((caught as Error).message).toBe("prompt timed out after 60ms");
213
+ expect(host.promptAsync).toHaveBeenCalledTimes(1);
214
+ expect(host.prompt).not.toHaveBeenCalled();
215
+ expect(host.abort).toHaveBeenCalledWith({ path: { id: "ses-child" } });
216
+ });
217
+ });