@cortexkit/opencode-magic-context 0.44.1 → 0.44.3

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 (216) hide show
  1. package/README.md +6 -0
  2. package/dist/agents/dreamer.d.ts +4 -6
  3. package/dist/agents/dreamer.d.ts.map +1 -1
  4. package/dist/config/live-child-output-cap.d.ts.map +1 -1
  5. package/dist/config/schema/magic-context.d.ts +63 -10
  6. package/dist/config/schema/magic-context.d.ts.map +1 -1
  7. package/dist/{conflict-warning-hook-d14ah73v.js → conflict-warning-hook-hmpzxep6.js} +3 -3
  8. package/dist/{derive-budgets-gd2270s0.js → derive-budgets-vjmvh6pz.js} +3 -1
  9. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  10. package/dist/features/magic-context/compartment-chunk-embedding.d.ts +11 -0
  11. package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
  12. package/dist/features/magic-context/compartment-storage.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  14. package/dist/features/magic-context/dreamer/docs-proposals.d.ts +2 -0
  15. package/dist/features/magic-context/dreamer/docs-proposals.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/hidden-single-shot.d.ts.map +1 -1
  17. package/dist/features/magic-context/dreamer/lease.d.ts +1 -1
  18. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  19. package/dist/features/magic-context/dreamer/manual-summary.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +1 -1
  21. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/map-memories.d.ts +5 -0
  23. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  24. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +5 -0
  25. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  26. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts +2 -2
  27. package/dist/features/magic-context/dreamer/retrospective-raw-provider.d.ts.map +1 -1
  28. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +8 -1
  29. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  30. package/dist/features/magic-context/dreamer/task-config.d.ts.map +1 -1
  31. package/dist/features/magic-context/dreamer/task-executor.d.ts +5 -0
  32. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  33. package/dist/features/magic-context/dreamer/task-gates.d.ts.map +1 -1
  34. package/dist/features/magic-context/dreamer/task-prompts.d.ts +18 -2
  35. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  36. package/dist/features/magic-context/dreamer/task-registry.d.ts +12 -0
  37. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
  38. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +3 -1
  39. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  40. package/dist/features/magic-context/dreamer/token-budget.d.ts +33 -0
  41. package/dist/features/magic-context/dreamer/token-budget.d.ts.map +1 -0
  42. package/dist/features/magic-context/dreamer/verify-diff.d.ts +15 -0
  43. package/dist/features/magic-context/dreamer/verify-diff.d.ts.map +1 -0
  44. package/dist/features/magic-context/dreamer/verify-gate.d.ts.map +1 -1
  45. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -2
  46. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  47. package/dist/features/magic-context/dreamer/verify-read-cap.d.ts +4 -0
  48. package/dist/features/magic-context/dreamer/verify-read-cap.d.ts.map +1 -0
  49. package/dist/features/magic-context/dreamer/verify.d.ts +5 -0
  50. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  51. package/dist/features/magic-context/git-commits/git-log-reader.d.ts.map +1 -1
  52. package/dist/features/magic-context/memory/project-identity-cache.d.ts +6 -0
  53. package/dist/features/magic-context/memory/project-identity-cache.d.ts.map +1 -0
  54. package/dist/features/magic-context/memory/project-identity.d.ts +11 -22
  55. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  56. package/dist/features/magic-context/memory/storage-memory-fts.d.ts +1 -0
  57. package/dist/features/magic-context/memory/storage-memory-fts.d.ts.map +1 -1
  58. package/dist/features/magic-context/memory/verification-paths.d.ts +1 -0
  59. package/dist/features/magic-context/memory/verification-paths.d.ts.map +1 -1
  60. package/dist/features/magic-context/message-index-async.d.ts +1 -1
  61. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  62. package/dist/features/magic-context/project-embedding-registry.d.ts +5 -0
  63. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  64. package/dist/features/magic-context/search.d.ts.map +1 -1
  65. package/dist/features/magic-context/session-activity.d.ts +10 -0
  66. package/dist/features/magic-context/session-activity.d.ts.map +1 -0
  67. package/dist/features/magic-context/session-project-backfill.d.ts +4 -1
  68. package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
  69. package/dist/features/magic-context/session-project-storage.d.ts +5 -5
  70. package/dist/features/magic-context/session-project-storage.d.ts.map +1 -1
  71. package/dist/features/magic-context/smart-notes/compiler.d.ts +5 -0
  72. package/dist/features/magic-context/smart-notes/compiler.d.ts.map +1 -1
  73. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts +1 -0
  74. package/dist/features/magic-context/smart-notes/sandbox-runner.d.ts.map +1 -1
  75. package/dist/features/magic-context/smart-notes/storage.d.ts +1 -1
  76. package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
  77. package/dist/features/magic-context/smart-notes/types.d.ts +2 -0
  78. package/dist/features/magic-context/smart-notes/types.d.ts.map +1 -1
  79. package/dist/features/magic-context/storage-db.d.ts +2 -1
  80. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  81. package/dist/features/magic-context/storage-embedding-measurements.d.ts.map +1 -1
  82. package/dist/features/magic-context/storage-identity-merge.d.ts +4 -1
  83. package/dist/features/magic-context/storage-identity-merge.d.ts.map +1 -1
  84. package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
  85. package/dist/features/magic-context/v22-deferred-backfill.d.ts.map +1 -1
  86. package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -1
  89. package/dist/hooks/magic-context/compartment-runner-historian.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/compartment-runner-recomp.d.ts.map +1 -1
  92. package/dist/hooks/magic-context/compartment-runner-types.d.ts +1 -0
  93. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  94. package/dist/hooks/magic-context/compartment-runner.d.ts +1 -0
  95. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/derive-budgets.d.ts +4 -0
  97. package/dist/hooks/magic-context/derive-budgets.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/dropped-input-guard.d.ts.map +1 -1
  99. package/dist/hooks/magic-context/embed-session-state.d.ts +3 -0
  100. package/dist/hooks/magic-context/embed-session-state.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/event-handler.d.ts +1 -0
  102. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/event-payloads.d.ts +1 -0
  104. package/dist/hooks/magic-context/event-payloads.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/historian-host-runner.d.ts.map +1 -1
  106. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  107. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  108. package/dist/hooks/magic-context/lkg-persist.d.ts +1 -4
  109. package/dist/hooks/magic-context/lkg-persist.d.ts.map +1 -1
  110. package/dist/hooks/magic-context/lkg-replay.d.ts +6 -4
  111. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  112. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  114. package/dist/hooks/magic-context/producer-window-guard.d.ts +4 -1
  115. package/dist/hooks/magic-context/producer-window-guard.d.ts.map +1 -1
  116. package/dist/hooks/magic-context/rust-mode-transform.d.ts +1 -0
  117. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  118. package/dist/hooks/magic-context/storage-busy-refusal.d.ts +13 -0
  119. package/dist/hooks/magic-context/storage-busy-refusal.d.ts.map +1 -0
  120. package/dist/hooks/magic-context/strip-content.d.ts +1 -0
  121. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  122. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  123. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  124. package/dist/{index-a9vbtdzv.js → index-33q7s9y2.js} +1 -1
  125. package/dist/{index-cz88kw0n.js → index-4an2p8qa.js} +183 -39
  126. package/dist/{index-d107az47.js → index-638hf49n.js} +1 -1
  127. package/dist/{index-stb5fg12.js → index-9y5p0d50.js} +31 -1
  128. package/dist/{index-s6b7h12g.js → index-dgg5y7er.js} +73 -2
  129. package/dist/{index-6w9p73jz.js → index-jqcbs2k1.js} +10 -0
  130. package/dist/{index-0rp6pj54.js → index-nf9sk8sh.js} +7 -5
  131. package/dist/index-q5p1aqvv.js +7260 -0
  132. package/dist/index-rdsaxnf2.js +125 -0
  133. package/dist/{index-jtdrvmcn.js → index-yhm60r53.js} +37544 -37072
  134. package/dist/index.d.ts.map +1 -1
  135. package/dist/index.js +8402 -14429
  136. package/dist/plugin/dream-timer.d.ts.map +1 -1
  137. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  138. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  139. package/dist/plugin/messages-transform.d.ts +10 -9
  140. package/dist/plugin/messages-transform.d.ts.map +1 -1
  141. package/dist/plugin/rpc-handlers.d.ts +1 -0
  142. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  143. package/dist/{read-session-chunk-fkdv2g5b.js → read-session-chunk-n072mcq4.js} +2 -2
  144. package/dist/{recomp-orchestrator-k4h5n0p3.js → recomp-orchestrator-19tr6zya.js} +6 -5
  145. package/dist/{send-session-notification-g2k03zyn.js → send-session-notification-04hvd54c.js} +2 -2
  146. package/dist/shared/model-suggestion-retry.d.ts +1 -1
  147. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  148. package/dist/shared/prompt-async-transport.d.ts +7 -7
  149. package/dist/shared/prompt-async-transport.d.ts.map +1 -1
  150. package/dist/shared/rpc-server.d.ts.map +1 -1
  151. package/dist/shared/rpc-utils.d.ts +21 -0
  152. package/dist/shared/rpc-utils.d.ts.map +1 -1
  153. package/dist/shared/run-token-log.d.ts +13 -0
  154. package/dist/shared/run-token-log.d.ts.map +1 -0
  155. package/dist/shared/sqlite-write-locker-test-support.d.ts.map +1 -1
  156. package/dist/shared/sqlite.d.ts +19 -0
  157. package/dist/shared/sqlite.d.ts.map +1 -1
  158. package/dist/shared/subagent-runner.d.ts +3 -1
  159. package/dist/shared/subagent-runner.d.ts.map +1 -1
  160. package/dist/shared/user-facing-codes.d.ts +5 -0
  161. package/dist/shared/user-facing-codes.d.ts.map +1 -1
  162. package/dist/{task-config-mgf5nsh3.js → task-config-2bm68628.js} +1 -1
  163. package/dist/transform-postprocess-phase-z2mfc5w1.js +36 -0
  164. package/dist/v2/hidden-completion.d.ts +9 -1
  165. package/dist/v2/hidden-completion.d.ts.map +1 -1
  166. package/dist/v2/hooks/context.d.ts.map +1 -1
  167. package/dist/v2/hooks/hidden-child.d.ts +1 -0
  168. package/dist/v2/hooks/hidden-child.d.ts.map +1 -1
  169. package/dist/v2/hooks/hidden-terminal-error.d.ts +8 -0
  170. package/dist/v2/hooks/hidden-terminal-error.d.ts.map +1 -0
  171. package/dist/v2/hooks/lkg-system.d.ts +11 -0
  172. package/dist/v2/hooks/lkg-system.d.ts.map +1 -0
  173. package/dist/v2/hooks/session-project-backfill.d.ts +1 -1
  174. package/dist/v2/hooks/session-project-backfill.d.ts.map +1 -1
  175. package/dist/v2/hooks/storage-gate.d.ts +11 -5
  176. package/dist/v2/hooks/storage-gate.d.ts.map +1 -1
  177. package/dist/v2/hooks/tools.d.ts +1 -1
  178. package/dist/v2/hooks/tools.d.ts.map +1 -1
  179. package/dist/v2/host-service.d.ts +2 -1
  180. package/dist/v2/host-service.d.ts.map +1 -1
  181. package/dist/v2/server.js +24948 -22659
  182. package/dist/v2/store-reader.d.ts +2 -0
  183. package/dist/v2/store-reader.d.ts.map +1 -1
  184. package/dist/{wrapup-orchestrator-35y56636.js → wrapup-orchestrator-sy4m56a7.js} +7 -6
  185. package/package.json +1 -1
  186. package/src/agents/dreamer.ts +4 -6
  187. package/src/agents/hidden-agent-registrations.ts +3 -3
  188. package/src/agents/permissions.test.ts +3 -4
  189. package/src/config/latch-permanence-guard.test.ts +13 -0
  190. package/src/config/live-child-output-cap.test.ts +26 -0
  191. package/src/config/live-child-output-cap.ts +6 -2
  192. package/src/config/schema/magic-context.ts +21 -6
  193. package/src/features/magic-context/dreamer/task-registry.ts +13 -0
  194. package/src/shared/data-path.test.ts +1 -1
  195. package/src/shared/logger.test.ts +1 -0
  196. package/src/shared/model-suggestion-retry.ts +9 -2
  197. package/src/shared/models-dev-cache.test.ts +30 -1
  198. package/src/shared/prompt-async-transport.test.ts +254 -1
  199. package/src/shared/prompt-async-transport.ts +153 -44
  200. package/src/shared/rpc-async-probes.test.ts +230 -0
  201. package/src/shared/rpc-server.ts +6 -4
  202. package/src/shared/rpc-utils.test.ts +27 -1
  203. package/src/shared/rpc-utils.ts +161 -9
  204. package/src/shared/run-token-log.test.ts +65 -0
  205. package/src/shared/run-token-log.ts +39 -0
  206. package/src/shared/sqlite-acquisition-fence.test.ts +132 -0
  207. package/src/shared/sqlite-acquisition-routing.test.ts +102 -0
  208. package/src/shared/sqlite-pass-scope.test.ts +153 -0
  209. package/src/shared/sqlite-transaction-mode-guard.test.ts +42 -10
  210. package/src/shared/sqlite-write-locker-test-support.ts +1 -0
  211. package/src/shared/sqlite.ts +245 -56
  212. package/src/shared/storage-busy.test.ts +72 -0
  213. package/src/shared/subagent-runner.ts +4 -0
  214. package/src/shared/user-facing-codes.ts +6 -0
  215. package/src/shared/windows-hidden-spawns.test.ts +132 -0
  216. package/src/shared/write-transaction-attribution.test.ts +14 -11
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, mock, test } from "bun:test";
2
-
2
+ import { refuseBudgetedToolCall } from "../features/magic-context/dreamer/token-budget";
3
+ import { createDroppedInputToolExecuteBeforeHook } from "../hooks/magic-context/dropped-input-guard";
3
4
  import { promptSyncWithValidatedOutputRetry } from "./model-suggestion-retry";
4
5
  import {
5
6
  createPromptAsyncTransport,
@@ -188,6 +189,258 @@ describe("promptAsyncAndWaitForIdle", () => {
188
189
  });
189
190
  });
190
191
 
192
+ describe("dreamer budget on an async child", () => {
193
+ test("sums prompt usage across validation retries in the same child", async () => {
194
+ const messages: unknown[] = [];
195
+ const states: Array<{ spent: number }> = [];
196
+ let sends = 0;
197
+ let aborted = false;
198
+ const client = {
199
+ session: {
200
+ messages: async () => ({ data: [...messages] }),
201
+ promptAsync: async (req: { body: { parts: unknown[] } }) => {
202
+ sends++;
203
+ messages.push({
204
+ info: { id: `user-${sends}`, role: "user" },
205
+ parts: req.body.parts,
206
+ });
207
+ return { data: undefined };
208
+ },
209
+ abort: async () => {
210
+ aborted = true;
211
+ return { data: true };
212
+ },
213
+ status: async () => {
214
+ const latest = messages.at(-1) as { info?: { id?: string } } | undefined;
215
+ if (sends === 1 && latest?.info?.id === "user-1")
216
+ messages.push({
217
+ info: {
218
+ id: "a1",
219
+ role: "assistant",
220
+ finish: "stop",
221
+ tokens: { input: 60 },
222
+ time: { created: 1, completed: 2 },
223
+ },
224
+ parts: [{ type: "text", text: "invalid manifest" }],
225
+ });
226
+ if (sends === 2 && latest?.info?.id === "user-2")
227
+ messages.push({
228
+ info: {
229
+ id: "a2",
230
+ role: "assistant",
231
+ finish: "tool-calls",
232
+ tokens: { input: 25 },
233
+ time: { created: 3, completed: 4 },
234
+ },
235
+ parts: [{ type: "tool" }],
236
+ });
237
+ if (sends === 3 && latest?.info?.id === "user-3")
238
+ messages.push({
239
+ info: {
240
+ id: "a3",
241
+ role: "assistant",
242
+ finish: "stop",
243
+ tokens: { input: 1 },
244
+ time: { created: 5, completed: 6 },
245
+ },
246
+ parts: [{ type: "text", text: "<mappings/>" }],
247
+ });
248
+ return {
249
+ data: sends === 2 && !aborted ? { "ses-child": { type: "busy" } } : {},
250
+ };
251
+ },
252
+ },
253
+ } as never;
254
+ const transport = createPromptAsyncTransport(client, "ses-child", {
255
+ pollIntervalMs: 1,
256
+ tokenBudget: 100,
257
+ onBudgetUpdate: (state) => states.push(state),
258
+ });
259
+ if (!transport) throw new Error("missing async transport");
260
+ await transport(request());
261
+ await transport(request());
262
+ expect(sends).toBe(3);
263
+ expect(states.at(-1)?.spent).toBe(86);
264
+ });
265
+ test("does not send a finalize turn when the child finishes below budget", async () => {
266
+ const host = asyncHost({ plan: ["busy", "settle"] });
267
+ const originalMessages = host.client.session.messages;
268
+ host.client.session.messages = mock(
269
+ async (...args: Parameters<typeof originalMessages>) => {
270
+ const response = await originalMessages(...args);
271
+ for (const message of response.data) {
272
+ if (message.info.role === "assistant") message.info.tokens = { input: 39 };
273
+ }
274
+ return response;
275
+ },
276
+ );
277
+ await promptAsyncAndWaitForIdle(host.client as never, request(), {
278
+ pollIntervalMs: 1,
279
+ tokenBudget: 100,
280
+ });
281
+ expect(host.promptAsync).toHaveBeenCalledTimes(1);
282
+ expect(host.abort).not.toHaveBeenCalled();
283
+ });
284
+ test("returns the final manifest after a soft nudge without changing the child agent", async () => {
285
+ const host = asyncHost({ plan: ["busy", "settle"] });
286
+ const messages: Array<{
287
+ info: {
288
+ id: string;
289
+ role: string;
290
+ tokens?: unknown;
291
+ time?: { completed: number };
292
+ finish?: string;
293
+ };
294
+ parts: unknown[];
295
+ }> = [];
296
+ let sends = 0;
297
+ const client = {
298
+ session: {
299
+ messages: async () => ({ data: [...messages] }),
300
+ promptAsync: mock(async (_req: unknown) => {
301
+ sends++;
302
+ messages.push({ info: { id: `u${sends}`, role: "user" }, parts: [] });
303
+ return { data: undefined };
304
+ }),
305
+ abort: host.abort,
306
+ status: async () => {
307
+ if (sends === 1 && !messages.some((m) => m.info.id === "a1")) {
308
+ messages.push({
309
+ info: {
310
+ id: "a1",
311
+ role: "assistant",
312
+ tokens: { input: 81 },
313
+ time: { completed: 1 },
314
+ finish: "tool-calls",
315
+ },
316
+ parts: [],
317
+ });
318
+ }
319
+ if (sends === 2 && !messages.some((m) => m.info.id === "a2")) {
320
+ messages.push({
321
+ info: {
322
+ id: "a2",
323
+ role: "assistant",
324
+ tokens: { input: 1 },
325
+ time: { completed: 2 },
326
+ finish: "stop",
327
+ },
328
+ parts: [{ type: "text", text: "<mappings/>" }],
329
+ });
330
+ }
331
+ return {
332
+ data:
333
+ sends === 1 && host.abort.mock.calls.length === 0
334
+ ? { "ses-child": { type: "busy" } }
335
+ : {},
336
+ };
337
+ },
338
+ },
339
+ } as never;
340
+ const body = {
341
+ ...request(),
342
+ body: {
343
+ agent: "dreamer-memory-mapper",
344
+ system: "same system",
345
+ parts: [{ type: "text", text: "work" }],
346
+ },
347
+ };
348
+ await promptAsyncAndWaitForIdle(client, body, { pollIntervalMs: 1, tokenBudget: 100 });
349
+ const prompts = (client as { session: { promptAsync: ReturnType<typeof mock> } }).session
350
+ .promptAsync.mock.calls;
351
+ expect(prompts).toHaveLength(2);
352
+ expect((prompts[1]?.[0] as typeof body).body.agent).toBe(
353
+ (prompts[0]?.[0] as typeof body).body.agent,
354
+ );
355
+ expect(messages.at(-1)?.parts).toEqual([{ type: "text", text: "<mappings/>" }]);
356
+ expect(host.abort).toHaveBeenCalledTimes(1);
357
+ });
358
+ test("finalizes once, refuses two tool calls, and keeps agent and tools unchanged", async () => {
359
+ const messages: Array<{
360
+ info: {
361
+ id: string;
362
+ role: string;
363
+ tokens?: unknown;
364
+ time?: { completed: number };
365
+ finish?: string;
366
+ };
367
+ parts: unknown[];
368
+ }> = [];
369
+ const toolsByAgent: Record<string, unknown[]> = {
370
+ "dreamer-memory-mapper": [{ name: "read", parameters: { type: "object" } }],
371
+ };
372
+ const capturedTools: string[] = [];
373
+ const prompts: unknown[] = [];
374
+ let stage = 0;
375
+ const client = {
376
+ session: {
377
+ messages: async () => ({ data: [...messages] }),
378
+ promptAsync: async (req: unknown) => {
379
+ prompts.push(req);
380
+ const agent = (req as { body: { agent: string } }).body.agent;
381
+ capturedTools.push(JSON.stringify(toolsByAgent[agent] ?? []));
382
+ if (prompts.length === 1)
383
+ messages.push({ info: { id: "u1", role: "user" }, parts: [] });
384
+ else messages.push({ info: { id: "u2", role: "user" }, parts: [] });
385
+ return { data: undefined };
386
+ },
387
+ abort: mock(async () => ({ data: true })),
388
+ status: async () => {
389
+ stage++;
390
+ if (stage === 1)
391
+ messages.push({
392
+ info: {
393
+ id: "a1",
394
+ role: "assistant",
395
+ tokens: { input: 10, cache: { read: 71, write: 0 } },
396
+ time: { completed: 1 },
397
+ finish: "tool-calls",
398
+ },
399
+ parts: [],
400
+ });
401
+ if (prompts.length === 2 && !messages.some((m) => m.info.id === "a2"))
402
+ messages.push({
403
+ info: {
404
+ id: "a2",
405
+ role: "assistant",
406
+ tokens: { input: 1, cache: { read: 1, write: 0 } },
407
+ time: { completed: 2 },
408
+ finish: "stop",
409
+ },
410
+ parts: [{ type: "text", text: "<manifest/>" }],
411
+ });
412
+ return { data: stage === 1 ? { "ses-child": { type: "busy" } } : {} };
413
+ },
414
+ },
415
+ } as never;
416
+ const run = promptAsyncAndWaitForIdle(
417
+ client,
418
+ {
419
+ ...request(),
420
+ body: { agent: "dreamer-memory-mapper", parts: [{ type: "text", text: "go" }] },
421
+ },
422
+ { pollIntervalMs: 1, tokenBudget: 100 },
423
+ );
424
+ // Wait for the follow-up to be enqueued before simulating the tool hook.
425
+ while (prompts.length < 2) await new Promise((resolve) => setTimeout(resolve, 1));
426
+ const before = createDroppedInputToolExecuteBeforeHook();
427
+ await expect(
428
+ before({ sessionID: "ses-child", tool: "read" }, { args: { path: "a" } }),
429
+ ).rejects.toThrow("Out of token budget");
430
+ await expect(
431
+ before({ sessionID: "ses-child", tool: "read" }, { args: { path: "b" } }),
432
+ ).rejects.toThrow("Out of token budget");
433
+ await expect(run).rejects.toThrow("token_budget");
434
+ expect(prompts).toHaveLength(2);
435
+ expect(capturedTools[0]).not.toBe("[]");
436
+ expect(capturedTools[1]).toBe(capturedTools[0]);
437
+ expect(
438
+ (prompts[1] as { body: { parts: Array<{ text: string }> } }).body.parts[0]?.text,
439
+ ).toContain("no more tool calls");
440
+ expect(refuseBudgetedToolCall("ses-child")).toBeNull();
441
+ });
442
+ });
443
+
191
444
  describe("prompt_async transport under the retry chain", () => {
192
445
  test("our slice stays the only timer and aborts the busy child when it expires", async () => {
193
446
  const host = asyncHost({ plan: ["busy"] });
@@ -1,5 +1,12 @@
1
1
  import type { createOpencodeClient } from "@opencode-ai/sdk";
2
-
2
+ import {
3
+ createDreamTokenBudget,
4
+ DreamTokenBudgetExceeded,
5
+ registerBudgetFinalizeChild,
6
+ releaseBudgetFinalizeChild,
7
+ TOKEN_BUDGET_FINALIZE_MESSAGE,
8
+ } from "../features/magic-context/dreamer/token-budget";
9
+ import { sumTokensFromChildMessages } from "../features/magic-context/subagent-token-capture";
3
10
  import type { PromptArgs, PromptTransport } from "./model-suggestion-retry";
4
11
 
5
12
  type Client = ReturnType<typeof createOpencodeClient> | undefined;
@@ -21,17 +28,26 @@ const DEFAULT_POLL_INTERVAL_MS = 1_000;
21
28
  const DEFAULT_START_GRACE_MS = 30_000;
22
29
  /** The wait only looks at the newest messages: the ones this send appended and
23
30
  * the ones just before it. The host returns the most recent `limit` messages. */
24
- const MESSAGE_WINDOW = 20;
31
+ const MESSAGE_WINDOW = 100;
25
32
 
26
33
  export interface PromptAsyncWaitOptions {
27
34
  pollIntervalMs?: number;
28
35
  startGraceMs?: number;
36
+ tokenBudget?: number;
37
+ /** Shared across retries of one child session. */
38
+ budgetGuard?: ReturnType<typeof createDreamTokenBudget>;
39
+ onBudgetUpdate?: (
40
+ state: ReturnType<ReturnType<typeof createDreamTokenBudget>["snapshot"]> & {
41
+ sessionId: string;
42
+ },
43
+ ) => void;
29
44
  }
30
45
 
31
46
  type SessionApi = {
32
47
  promptAsync?: (options: unknown) => Promise<unknown>;
33
48
  status?: (options?: unknown) => Promise<unknown>;
34
49
  messages?: (options: unknown) => Promise<unknown>;
50
+ abort?: (options: unknown) => Promise<unknown>;
35
51
  };
36
52
 
37
53
  function sessionApi(client: Client): SessionApi | undefined {
@@ -59,8 +75,12 @@ export function createPromptAsyncTransport(
59
75
  options: PromptAsyncWaitOptions = {},
60
76
  ): PromptTransport | undefined {
61
77
  if (!supportsPromptAsync(client)) return undefined;
78
+ const budgetGuard = options.tokenBudget
79
+ ? createDreamTokenBudget(options.tokenBudget)
80
+ : undefined;
62
81
  return Object.assign(
63
- (request: PromptArgs) => promptAsyncAndWaitForIdle(client, request, options),
82
+ (request: PromptArgs) =>
83
+ promptAsyncAndWaitForIdle(client, request, { ...options, budgetGuard }),
64
84
  { childSessionId },
65
85
  );
66
86
  }
@@ -90,6 +110,23 @@ function isSettledAssistant(message: unknown): boolean {
90
110
  return info.time?.completed != null || info.error != null || info.finish != null;
91
111
  }
92
112
 
113
+ function isTerminalAssistant(message: unknown): boolean {
114
+ const info = infoOf(message);
115
+ return (
116
+ isSettledAssistant(message) &&
117
+ info.finish !== "tool-calls" &&
118
+ info.finish !== "tool_use" &&
119
+ !(
120
+ message &&
121
+ typeof message === "object" &&
122
+ Array.isArray((message as { parts?: unknown[] }).parts) &&
123
+ (message as { parts: Array<{ type?: string }> }).parts.some(
124
+ (part) => part.type === "tool",
125
+ )
126
+ )
127
+ );
128
+ }
129
+
93
130
  function unwrapData(response: unknown): unknown {
94
131
  if (response && typeof response === "object" && "data" in response) {
95
132
  return (response as { data?: unknown }).data;
@@ -169,13 +206,6 @@ async function readStatus(
169
206
  }
170
207
  }
171
208
 
172
- /**
173
- * Send one prompt with `prompt_async`, then poll until the child session is idle
174
- * and its newest message is a finished assistant reply from this send. Returns
175
- * without judging that reply: the caller's output validation reads it. Rejects
176
- * when the caller's signal aborts, and when the host accepted the send but never
177
- * started a run within the start grace.
178
- */
179
209
  export async function promptAsyncAndWaitForIdle(
180
210
  client: Client,
181
211
  request: PromptArgs,
@@ -191,52 +221,131 @@ export async function promptAsyncAndWaitForIdle(
191
221
  const signal = request.signal;
192
222
  const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
193
223
  const startGraceMs = options.startGraceMs ?? DEFAULT_START_GRACE_MS;
194
-
195
- // A fallback attempt reuses the child session, so an earlier attempt's final
196
- // reply must not be mistaken for this send's reply.
197
- const baseline = new Set(
224
+ const budget =
225
+ options.budgetGuard ??
226
+ (options.tokenBudget ? createDreamTokenBudget(options.tokenBudget) : undefined);
227
+ if (budget?.snapshot().finalizeFired)
228
+ throw new DreamTokenBudgetExceeded(sessionId, budget.snapshot().spent);
229
+ const seenUsage = new Set<string>();
230
+ let finalizing = false;
231
+ let completionBaseline = new Set(
198
232
  (await readMessages(session, sessionId, dir, signal))
199
233
  .map(messageId)
200
234
  .filter((id): id is string => id !== null),
201
235
  );
202
-
236
+ const initialBaseline = new Set(completionBaseline);
203
237
  const sent = await session.promptAsync?.(request);
204
238
  if (sent && typeof sent === "object" && "error" in sent) {
205
239
  const rejection = (sent as { error?: unknown }).error;
206
240
  if (rejection)
207
241
  throw new Error(`prompt_async was rejected: ${describeRejection(rejection)}`);
208
242
  }
209
- const sentAt = Date.now();
243
+ let sentAt = Date.now();
210
244
  let sawBusy = false;
211
245
 
212
- for (;;) {
213
- await sleep(pollIntervalMs, signal);
214
- const status = await readStatus(session, sessionId, dir, signal);
215
- if (status === "busy" || status === "retry") {
216
- sawBusy = true;
217
- continue;
218
- }
219
- // An unreadable status says nothing about the run; keep waiting under the
220
- // caller's timer rather than guessing.
221
- if (status === null) continue;
222
-
223
- const messages = await readMessages(session, sessionId, dir, signal);
224
- const last = messages.at(-1);
225
- const lastId = messageId(last);
226
- if (last && lastId !== null && !baseline.has(lastId) && isSettledAssistant(last)) return;
227
-
228
- const fresh = messages.some((message) => {
229
- const id = messageId(message);
230
- return id !== null && !baseline.has(id);
231
- });
232
- // The loop ran and ended without a final reply (for example it failed
233
- // before writing one). Output validation reports what is missing.
234
- if (sawBusy && fresh) return;
235
- if (Date.now() - sentAt >= startGraceMs) {
236
- if (fresh) return;
237
- throw new Error(
238
- `prompt_async did not start a run in child session ${sessionId} within ${startGraceMs}ms`,
239
- );
246
+ try {
247
+ for (;;) {
248
+ await sleep(pollIntervalMs, signal);
249
+ const status = await readStatus(session, sessionId, dir, signal);
250
+ const messages = await readMessages(session, sessionId, dir, signal);
251
+ if (budget) {
252
+ for (const message of messages) {
253
+ const id = messageId(message);
254
+ if (!id || seenUsage.has(id) || infoOf(message).role !== "assistant") continue;
255
+ // The initial baseline belongs to a previous child attempt.
256
+ if (initialBaseline.has(id)) continue;
257
+ const tokens = sumTokensFromChildMessages([message]);
258
+ if (tokens.input + tokens.cacheRead + tokens.cacheWrite === 0) continue;
259
+ seenUsage.add(id);
260
+ const decision = budget.charge(
261
+ tokens.input,
262
+ tokens.cacheRead,
263
+ tokens.cacheWrite,
264
+ isTerminalAssistant(message),
265
+ );
266
+ options.onBudgetUpdate?.({ ...budget.snapshot(), sessionId });
267
+ if (decision === "stop") {
268
+ await session.abort?.({ path: { id: sessionId } });
269
+ throw new DreamTokenBudgetExceeded(sessionId, budget.snapshot().spent);
270
+ }
271
+ if (decision === "finalize") {
272
+ finalizing = true;
273
+ registerBudgetFinalizeChild(sessionId, budget);
274
+ if (!session.abort)
275
+ throw new DreamTokenBudgetExceeded(sessionId, budget.snapshot().spent);
276
+ await session.abort({ path: { id: sessionId } });
277
+ // Wait for the cancelled generation to become idle before
278
+ // submitting a new user turn in the same session.
279
+ const abortDeadline = Date.now() + startGraceMs;
280
+ while (Date.now() < abortDeadline) {
281
+ const state = await readStatus(session, sessionId, dir, signal);
282
+ if (state === "idle") break;
283
+ await sleep(pollIntervalMs, signal);
284
+ }
285
+ if ((await readStatus(session, sessionId, dir, signal)) !== "idle") {
286
+ throw new DreamTokenBudgetExceeded(sessionId, budget.snapshot().spent);
287
+ }
288
+ // An aborted assistant is not the answer to the new user turn.
289
+ completionBaseline = new Set(
290
+ (await readMessages(session, sessionId, dir, signal))
291
+ .map(messageId)
292
+ .filter((value): value is string => value !== null),
293
+ );
294
+ const finalizeResponse = await session.promptAsync?.({
295
+ ...request,
296
+ body: {
297
+ ...request.body,
298
+ parts: [{ type: "text", text: TOKEN_BUDGET_FINALIZE_MESSAGE }],
299
+ },
300
+ });
301
+ if (
302
+ finalizeResponse &&
303
+ typeof finalizeResponse === "object" &&
304
+ "error" in finalizeResponse &&
305
+ finalizeResponse.error
306
+ ) {
307
+ throw new DreamTokenBudgetExceeded(sessionId, budget.snapshot().spent);
308
+ }
309
+ sentAt = Date.now();
310
+ sawBusy = false;
311
+ break;
312
+ }
313
+ }
314
+ if (budget.snapshot().hardStopped) {
315
+ await session.abort?.({ path: { id: sessionId } });
316
+ throw new DreamTokenBudgetExceeded(sessionId, budget.snapshot().spent);
317
+ }
318
+ }
319
+ if (status === "busy" || status === "retry") {
320
+ sawBusy = true;
321
+ continue;
322
+ }
323
+ if (status === null) continue;
324
+ const current = finalizing
325
+ ? await readMessages(session, sessionId, dir, signal)
326
+ : messages;
327
+ const last = current.at(-1);
328
+ const lastId = messageId(last);
329
+ if (
330
+ last &&
331
+ lastId !== null &&
332
+ !completionBaseline.has(lastId) &&
333
+ isSettledAssistant(last)
334
+ )
335
+ return;
336
+ const fresh = current.some((message) => {
337
+ const id = messageId(message);
338
+ return id !== null && !completionBaseline.has(id);
339
+ });
340
+ if (sawBusy && fresh) return;
341
+ if (Date.now() - sentAt >= startGraceMs) {
342
+ if (fresh) return;
343
+ throw new Error(
344
+ `prompt_async did not start a run in child session ${sessionId} within ${startGraceMs}ms`,
345
+ );
346
+ }
240
347
  }
348
+ } finally {
349
+ if (budget) releaseBudgetFinalizeChild(sessionId);
241
350
  }
242
351
  }