@cortexkit/opencode-magic-context 0.49.17 → 0.49.19

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 (123) hide show
  1. package/dist/auto-search-worker.js +6 -2
  2. package/dist/chunk-embedding-write-worker.js +2 -2
  3. package/dist/{context-yhetp0kn.js → context-j31m2dv7.js} +27 -14
  4. package/dist/features/magic-context/dreamer/classify.d.ts +3 -0
  5. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  6. package/dist/features/magic-context/dreamer/hidden-single-shot.d.ts +11 -0
  7. package/dist/features/magic-context/dreamer/hidden-single-shot.d.ts.map +1 -1
  8. package/dist/features/magic-context/dreamer/map-memories.d.ts +3 -0
  9. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/verify.d.ts +3 -0
  11. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  12. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  13. package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
  14. package/dist/features/magic-context/storage-host-instruction-retention.d.ts +11 -0
  15. package/dist/features/magic-context/storage-host-instruction-retention.d.ts.map +1 -0
  16. package/dist/features/magic-context/storage-meta-persisted.d.ts +13 -0
  17. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  18. package/dist/hooks/magic-context/auto-search-worker-evaluation.fixture.d.ts +2 -0
  19. package/dist/hooks/magic-context/auto-search-worker-evaluation.fixture.d.ts.map +1 -0
  20. package/dist/hooks/magic-context/cache-busting-signals.d.ts +22 -1
  21. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
  22. package/dist/hooks/magic-context/compartment-trigger.d.ts +4 -2
  23. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  24. package/dist/hooks/magic-context/file-token-coverage.d.ts +11 -0
  25. package/dist/hooks/magic-context/file-token-coverage.d.ts.map +1 -0
  26. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +9 -0
  27. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
  28. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  29. package/dist/hooks/magic-context/host-served-rows.d.ts +25 -0
  30. package/dist/hooks/magic-context/host-served-rows.d.ts.map +1 -1
  31. package/dist/hooks/magic-context/inject-compartments.d.ts +7 -1
  32. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  33. package/dist/hooks/magic-context/lkg-recovery-prefix.d.ts +13 -0
  34. package/dist/hooks/magic-context/lkg-recovery-prefix.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  36. package/dist/hooks/magic-context/lkg-slot.d.ts +1 -1
  37. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/pass-outcome.d.ts +2 -0
  39. package/dist/hooks/magic-context/pass-outcome.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/protected-tail-boundary.d.ts +2 -1
  41. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
  42. package/dist/hooks/magic-context/signed-thinking-hold.d.ts +59 -0
  43. package/dist/hooks/magic-context/signed-thinking-hold.d.ts.map +1 -0
  44. package/dist/hooks/magic-context/system-date-refresh.d.ts +15 -2
  45. package/dist/hooks/magic-context/system-date-refresh.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +6 -0
  50. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/transform-context-state.d.ts +19 -0
  52. package/dist/hooks/magic-context/transform-context-state.d.ts.map +1 -1
  53. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +21 -0
  54. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  56. package/dist/{index-aabzay3h.js → index-3esx8tkf.js} +256 -50
  57. package/dist/{index-v8awdgvd.js → index-3h4dp9wb.js} +1 -1
  58. package/dist/{index-5a1ex6x8.js → index-4911rn0b.js} +3 -3
  59. package/dist/{index-qn7xstt2.js → index-63k48rv9.js} +1 -2
  60. package/dist/{index-p7m7j0cp.js → index-9zcsy3hh.js} +53 -7
  61. package/dist/{index-nkpmm9q8.js → index-c1p88z1y.js} +3 -3
  62. package/dist/{index-hqhm3wke.js → index-cf74k5ak.js} +2 -2
  63. package/dist/{index-vrzjhftj.js → index-dxmq29t1.js} +4 -4
  64. package/dist/{index-kmdj7dsq.js → index-edmpdxcw.js} +1 -1
  65. package/dist/{index-4yt7dtf4.js → index-jhghdnsm.js} +113 -44
  66. package/dist/{index-fb2qxs2y.js → index-jrr131e0.js} +2 -2
  67. package/dist/{index-a7c4r5re.js → index-rpxdjkm0.js} +216 -40
  68. package/dist/{index-cwzfcbtq.js → index-se4m2g6w.js} +10 -1
  69. package/dist/index.js +21 -17
  70. package/dist/{message-index-bounded-1633vc7f.js → message-index-bounded-zyc8h32y.js} +3 -3
  71. package/dist/{message-index-dprebmtt.js → message-index-gtpqnj8w.js} +3 -3
  72. package/dist/migration-worker.js +2 -2
  73. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  74. package/dist/plugin/messages-transform.d.ts.map +1 -1
  75. package/dist/plugin/tool-registry.d.ts.map +1 -1
  76. package/dist/raw-ordinal-warmup-worker.js +4 -4
  77. package/dist/{read-session-chunk-4zp09ejb.js → read-session-chunk-jjb48bt9.js} +2 -2
  78. package/dist/{recomp-orchestrator-z3ecszx6.js → recomp-orchestrator-999jj4bc.js} +8 -8
  79. package/dist/{search-archive-znyy44ve.js → search-archive-6vm0e2f5.js} +3 -3
  80. package/dist/{search-6mee3vty.js → search-xsqk1bj4.js} +6 -6
  81. package/dist/shared/model-suggestion-retry.d.ts +30 -1
  82. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  83. package/dist/shared/prompt-async-transport.d.ts.map +1 -1
  84. package/dist/{storage-tags-8qns3012.js → storage-tags-r7k9tjcv.js} +1 -1
  85. package/dist/tools/ctx-expand/rust-mode-tag.test-support.d.ts +15 -0
  86. package/dist/tools/ctx-expand/rust-mode-tag.test-support.d.ts.map +1 -0
  87. package/dist/tools/ctx-expand/tools.d.ts +11 -0
  88. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  89. package/dist/{transform-postprocess-phase-3ydzxa62.js → transform-postprocess-phase-jj99r7z2.js} +8 -8
  90. package/dist/v2/auto-search-worker.js +6 -2
  91. package/dist/v2/chunk-embedding-write-worker.js +2 -2
  92. package/dist/v2/{context-jn3zpeqh.js → context-y5yamxak.js} +378 -92
  93. package/dist/v2/hooks/context.d.ts.map +1 -1
  94. package/dist/v2/hooks/payload.d.ts.map +1 -1
  95. package/dist/v2/hooks/tools.d.ts.map +1 -1
  96. package/dist/v2/{message-index-pxehjpzx.js → message-index-8vz2pq76.js} +3 -3
  97. package/dist/v2/{message-index-bounded-hpejqyd8.js → message-index-bounded-spd8r7pj.js} +3 -3
  98. package/dist/v2/migration-worker.js +2 -2
  99. package/dist/v2/raw-ordinal-warmup-worker.js +4 -4
  100. package/dist/v2/{read-session-chunk-zdppn4bj.js → read-session-chunk-cwkxrmns.js} +2 -2
  101. package/dist/v2/{recomp-orchestrator-cmqnfpp3.js → recomp-orchestrator-f6m9jr7p.js} +7 -7
  102. package/dist/v2/{search-archive-gfh11vs0.js → search-archive-wve3tq1k.js} +3 -3
  103. package/dist/v2/{search-66842dya.js → search-hwg7jfbj.js} +6 -6
  104. package/dist/v2/{server-zyzawhdw.js → server-27gq4mfz.js} +2 -2
  105. package/dist/v2/{server-3rhpvy84.js → server-2ar62dyx.js} +10 -1
  106. package/dist/v2/{server-ec4ca92p.js → server-45q69hkq.js} +1 -1
  107. package/dist/v2/{server-fw7wrk01.js → server-6vdzn1td.js} +216 -40
  108. package/dist/v2/{server-kh7dpa1a.js → server-9hx7g5jp.js} +53 -7
  109. package/dist/v2/{server-gtfhttbr.js → server-bmymkx73.js} +2 -2
  110. package/dist/v2/{server-vsw8tmm6.js → server-gsxjv2q2.js} +4 -4
  111. package/dist/v2/{server-my7k4eds.js → server-hm38q7r0.js} +1 -1
  112. package/dist/v2/{server-gpvay0xz.js → server-xq7dfjke.js} +1 -2
  113. package/dist/v2/server.js +2 -2
  114. package/dist/v2/{storage-tags-1h8k6ra9.js → storage-tags-3wdb8zph.js} +1 -1
  115. package/dist/v2/store-reader.d.ts +6 -0
  116. package/dist/v2/store-reader.d.ts.map +1 -1
  117. package/dist/v2/{wrapup-orchestrator-xs8redz9.js → wrapup-orchestrator-embdz8gj.js} +7 -7
  118. package/dist/{wrapup-orchestrator-j7d7ry5b.js → wrapup-orchestrator-whhbjmaz.js} +9 -9
  119. package/package.json +1 -1
  120. package/src/shared/model-suggestion-retry.test.ts +166 -0
  121. package/src/shared/model-suggestion-retry.ts +165 -14
  122. package/src/shared/prompt-async-transport.test.ts +107 -0
  123. package/src/shared/prompt-async-transport.ts +39 -0
@@ -89,11 +89,26 @@ export interface PromptRetryOptions {
89
89
  * still self-heal at the primary AND at each fallback).
90
90
  * - Iteration stops immediately on abort/timeout/context-overflow errors —
91
91
  * fallbacks won't help and the caller's emergency-recovery path needs
92
- * to handle these.
92
+ * to handle these. The exception is an attempt cut by
93
+ * `firstOutputTimeoutMs` (see there), which moves on to the next model.
93
94
  * - On all-failed, the LAST error is thrown (matches legacy behavior when
94
95
  * `fallbackModels` is empty).
95
96
  */
96
97
  fallbackModels?: readonly ModelInput[];
98
+ /**
99
+ * How long an attempt may go without the model producing anything (no
100
+ * streamed text, reasoning, tool call, or token usage) before the model is
101
+ * treated as hung. The attempt is then stopped and the next configured model
102
+ * runs with whatever is left of `timeoutMs`, so a dead model costs this
103
+ * bound instead of the whole timeout. An attempt that has produced output
104
+ * keeps its full timeout and, if that expires, still ends the chain.
105
+ *
106
+ * Applies only to an attempt that has a later model to move to, is capped at
107
+ * half that attempt's timeout so the next model keeps a usable share, and
108
+ * only works with transports that watch the child for output (OpenCode's
109
+ * `prompt_async` wait). Undefined = no first-output deadline.
110
+ */
111
+ firstOutputTimeoutMs?: number;
97
112
  /**
98
113
  * Identifier for structured logging (e.g. "dreamer:consolidate",
99
114
  * "historian", "compressor", "dreamer"). Helps correlate fallback
@@ -168,9 +183,53 @@ export function isHostTimeoutError(error: unknown): boolean {
168
183
  );
169
184
  }
170
185
 
186
+ /**
187
+ * The model chain cut this attempt short because the model had produced nothing
188
+ * (no streamed text, reasoning, tool call, or token usage) by the first-output
189
+ * deadline. A model that has not answered at all is treated like a provider
190
+ * failure, so the next configured model gets the rest of the time.
191
+ */
192
+ export class ModelProducedNoOutputError extends Error {
193
+ constructor(readonly waitedMs: number) {
194
+ // Keeps "timed out after Nms" so ledgers record the attempt as timed out.
195
+ super(`prompt timed out after ${waitedMs}ms with no model output`);
196
+ this.name = "ModelProducedNoOutputError";
197
+ }
198
+ }
199
+
200
+ /**
201
+ * What a transport has seen of the model during one attempt. `monitored` means
202
+ * the transport watches the child's messages and will set `received` as soon as
203
+ * the model streams anything. Transports that cannot see progress never set
204
+ * `monitored`, and the first-output deadline then never fires for them.
205
+ */
206
+ interface ModelActivity {
207
+ monitored: boolean;
208
+ received: boolean;
209
+ }
210
+
211
+ /** Keyed by the per-attempt abort signal that `promptWithTimeout` hands the transport. */
212
+ const modelActivityBySignal = new WeakMap<AbortSignal, ModelActivity>();
213
+
214
+ /** A transport calls this once it is watching the child for model output. */
215
+ export function noteModelActivityMonitored(signal: AbortSignal | undefined): void {
216
+ const activity = signal ? modelActivityBySignal.get(signal) : undefined;
217
+ if (activity) activity.monitored = true;
218
+ }
219
+
220
+ /** A transport calls this when it sees the model produce anything for this attempt. */
221
+ export function noteModelOutputReceived(signal: AbortSignal | undefined): void {
222
+ const activity = signal ? modelActivityBySignal.get(signal) : undefined;
223
+ if (activity) {
224
+ activity.monitored = true;
225
+ activity.received = true;
226
+ }
227
+ }
228
+
171
229
  /** Our own slice expiry (see `promptWithTimeout`) or a host request timer. */
172
230
  export function isPromptTimeoutError(error: unknown): boolean {
173
231
  if (isHostTimeoutError(error)) return true;
232
+ if (error instanceof ModelProducedNoOutputError) return true;
174
233
  return error instanceof Error && /^prompt timed out after \d+ms$/.test(error.message);
175
234
  }
176
235
 
@@ -275,6 +334,7 @@ async function promptWithTimeout(
275
334
  timeoutMs: number,
276
335
  signal?: AbortSignal,
277
336
  transport?: PromptTransport,
337
+ firstOutputTimeoutMs?: number,
278
338
  ): Promise<void> {
279
339
  // Bail immediately if the caller's signal is already aborted (e.g.
280
340
  // lease loss before this attempt was scheduled). Per spec
@@ -287,6 +347,23 @@ async function promptWithTimeout(
287
347
  }
288
348
  const controller = new AbortController();
289
349
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
350
+ const activity: ModelActivity = { monitored: false, received: false };
351
+ modelActivityBySignal.set(controller.signal, activity);
352
+ // Set when the first-output deadline, not the attempt timeout, ended the attempt.
353
+ let silent = false;
354
+ const firstOutputTimer =
355
+ firstOutputTimeoutMs !== undefined && firstOutputTimeoutMs < timeoutMs
356
+ ? setTimeout(() => {
357
+ // Only a transport that watches the child can say the model is silent.
358
+ if (!activity.monitored || activity.received) return;
359
+ silent = true;
360
+ controller.abort();
361
+ }, firstOutputTimeoutMs)
362
+ : undefined;
363
+ const timeoutError = () =>
364
+ silent && firstOutputTimeoutMs !== undefined
365
+ ? new ModelProducedNoOutputError(firstOutputTimeoutMs)
366
+ : new Error(`prompt timed out after ${timeoutMs}ms`);
290
367
 
291
368
  // Link external signal to internal controller so external abort cancels the fetch
292
369
  const onExternalAbort = () => controller.abort();
@@ -319,7 +396,7 @@ async function promptWithTimeout(
319
396
  if (!transport || transport.childSessionId) {
320
397
  await abortChildRun(client, transport?.childSessionId ?? args.path.id);
321
398
  }
322
- throw new Error(`prompt timed out after ${timeoutMs}ms`);
399
+ throw timeoutError();
323
400
  }
324
401
  if (isHostTimeoutError(error)) {
325
402
  // The host client's own request timer fired. Only our side of the
@@ -333,6 +410,7 @@ async function promptWithTimeout(
333
410
  throw error;
334
411
  } finally {
335
412
  clearTimeout(timeout);
413
+ if (firstOutputTimer) clearTimeout(firstOutputTimer);
336
414
  signal?.removeEventListener("abort", onExternalAbort);
337
415
  }
338
416
  // Some transports resolve with an error result when their fetch is aborted.
@@ -341,11 +419,7 @@ async function promptWithTimeout(
341
419
  if (!transport || transport.childSessionId) {
342
420
  await abortChildRun(client, transport?.childSessionId ?? args.path.id);
343
421
  }
344
- throw new Error(
345
- signal?.aborted
346
- ? externalAbortMessage(signal)
347
- : `prompt timed out after ${timeoutMs}ms`,
348
- );
422
+ throw signal?.aborted ? new Error(externalAbortMessage(signal)) : timeoutError();
349
423
  }
350
424
  }
351
425
 
@@ -382,7 +456,12 @@ async function abortChildRun(client: Client, sessionId: string): Promise<void> {
382
456
  * model. Caller has its own emergency-recovery path for this.
383
457
  * - Timeout — same wall-clock budget on the same prompt is unlikely to
384
458
  * succeed on another model. Caller decides whether to retry at a higher
385
- * level (e.g. historian's MAX_HISTORIAN_RETRIES loop).
459
+ * level (e.g. historian's MAX_HISTORIAN_RETRIES loop). This covers an
460
+ * attempt that ran out its whole time, whether or not the model answered.
461
+ *
462
+ * A `ModelProducedNoOutputError` is the exception: no output was observed
463
+ * before the first-output deadline, which points at the model rather than the
464
+ * prompt, so the chain gives the next model the time that is left.
386
465
  *
387
466
  * Everything else (auth errors, ProviderModelNotFoundError without suggestion,
388
467
  * rate limits, transient network failures, etc.) is considered retryable on a
@@ -397,6 +476,8 @@ function isNonRetryable(error: unknown, externalSignal?: AbortSignal): boolean {
397
476
  )
398
477
  return true;
399
478
 
479
+ if (error instanceof ModelProducedNoOutputError) return false;
480
+
400
481
  if (error instanceof Error) {
401
482
  if (error.name === "AbortError") return true;
402
483
  // promptWithTimeout wraps both abort cases in plain `Error` with a
@@ -442,7 +523,11 @@ function classifyPromptFailure(
442
523
  if (externalSignal?.aborted || message === "prompt aborted by external signal") {
443
524
  return "child_aborted";
444
525
  }
445
- if (/^prompt timed out after \d+ms$/.test(message) || isHostTimeoutError(error)) {
526
+ if (
527
+ error instanceof ModelProducedNoOutputError ||
528
+ /^prompt timed out after \d+ms$/.test(message) ||
529
+ isHostTimeoutError(error)
530
+ ) {
446
531
  return "provider_timeout";
447
532
  }
448
533
  if (phase === "validation") {
@@ -513,6 +598,7 @@ async function attemptOnce(
513
598
  label: string,
514
599
  transport?: PromptTransport,
515
600
  allowModelSuggestions = true,
601
+ firstOutputTimeoutMs?: number,
516
602
  ): Promise<void> {
517
603
  // Keep this snapshot separate from the object passed to the client. A
518
604
  // failed prompt facade may rewrite its request body before rejecting; the
@@ -520,7 +606,14 @@ async function attemptOnce(
520
606
  const originalBody = { ...args.body };
521
607
  const attemptArgs = copyPromptArgs(args, originalBody);
522
608
  try {
523
- await promptWithTimeout(client, attemptArgs, timeoutMs, signal, transport);
609
+ await promptWithTimeout(
610
+ client,
611
+ attemptArgs,
612
+ timeoutMs,
613
+ signal,
614
+ transport,
615
+ firstOutputTimeoutMs,
616
+ );
524
617
  return;
525
618
  } catch (error) {
526
619
  // Non-retryable failures and callers with model suggestions disabled
@@ -551,10 +644,48 @@ async function attemptOnce(
551
644
  timeoutMs,
552
645
  signal,
553
646
  transport,
647
+ firstOutputTimeoutMs,
554
648
  );
555
649
  }
556
650
  }
557
651
 
652
+ /**
653
+ * Per-attempt timing across one model chain. An attempt cut by the first-output
654
+ * deadline has the time it used taken out of every later attempt's timeout, so
655
+ * a silent model never stretches the caller's overall wait past `timeoutMs`.
656
+ * Attempts that fail for other reasons keep the existing full per-attempt budget.
657
+ */
658
+ function createChainClock(options: PromptRetryOptions, timeoutMs: number) {
659
+ const fallbacks = options.fallbackModels ?? [];
660
+ const hasValidFallbackFrom = (index: number) =>
661
+ fallbacks.slice(index).some((fallback) => {
662
+ const entry = toModelEntry(fallback);
663
+ return entry ? parseProviderModel(entry.model) !== null : false;
664
+ });
665
+ let silentSpentMs = 0;
666
+ return {
667
+ /** Budget for an attempt whose next candidate is `fallbacks[nextFallbackIndex]`. */
668
+ budget(nextFallbackIndex: number): { timeoutMs: number; firstOutputTimeoutMs?: number } {
669
+ const attemptTimeoutMs = Math.max(1, timeoutMs - silentSpentMs);
670
+ // The last model in the chain has nothing to move to, so it keeps the
671
+ // plain timeout rather than being cut short for no benefit.
672
+ if (
673
+ options.firstOutputTimeoutMs === undefined ||
674
+ !hasValidFallbackFrom(nextFallbackIndex)
675
+ )
676
+ return { timeoutMs: attemptTimeoutMs };
677
+ const bound = Math.min(options.firstOutputTimeoutMs, Math.floor(attemptTimeoutMs / 2));
678
+ return bound > 0
679
+ ? { timeoutMs: attemptTimeoutMs, firstOutputTimeoutMs: bound }
680
+ : { timeoutMs: attemptTimeoutMs };
681
+ },
682
+ settle(error: unknown, startedAt: number): void {
683
+ if (error instanceof ModelProducedNoOutputError)
684
+ silentSpentMs += Date.now() - startedAt;
685
+ },
686
+ };
687
+ }
688
+
558
689
  /**
559
690
  * Run an OpenCode subagent prompt with model fallback support.
560
691
  *
@@ -591,21 +722,26 @@ export async function promptSyncWithModelSuggestionRetry(
591
722
  : "primary";
592
723
 
593
724
  let lastError: unknown = null;
725
+ const clock = createChainClock(options, timeoutMs);
594
726
 
727
+ const primaryStartedAt = Date.now();
595
728
  try {
729
+ const budget = clock.budget(0);
596
730
  await attemptOnce(
597
731
  client,
598
732
  baseArgs,
599
- timeoutMs,
733
+ budget.timeoutMs,
600
734
  options.signal,
601
735
  callContext,
602
736
  explicitPrimaryLabel,
603
737
  options.transport,
604
738
  options.allowModelSuggestions,
739
+ budget.firstOutputTimeoutMs,
605
740
  );
606
741
  return;
607
742
  } catch (error) {
608
743
  lastError = error;
744
+ clock.settle(error, primaryStartedAt);
609
745
  if (isNonRetryable(error, options.signal)) throw error;
610
746
 
611
747
  if (fallbacks.length === 0) {
@@ -637,16 +773,19 @@ export async function promptSyncWithModelSuggestionRetry(
637
773
  ...(fallback?.qualifier ? { variant: fallback.qualifier } : {}),
638
774
  });
639
775
 
776
+ const attemptStartedAt = Date.now();
640
777
  try {
778
+ const budget = clock.budget(i + 1);
641
779
  await attemptOnce(
642
780
  client,
643
781
  attemptArgs,
644
- timeoutMs,
782
+ budget.timeoutMs,
645
783
  options.signal,
646
784
  callContext,
647
785
  label,
648
786
  options.transport,
649
787
  options.allowModelSuggestions,
788
+ budget.firstOutputTimeoutMs,
650
789
  );
651
790
  log(
652
791
  `[${callContext}] fallback succeeded with ${label} (attempt ${i + 2}/${fallbacks.length + 1})`,
@@ -654,6 +793,7 @@ export async function promptSyncWithModelSuggestionRetry(
654
793
  return;
655
794
  } catch (error) {
656
795
  lastError = error;
796
+ clock.settle(error, attemptStartedAt);
657
797
  if (isNonRetryable(error, options.signal)) throw error;
658
798
 
659
799
  const remaining = fallbacks.length - i - 1;
@@ -682,6 +822,7 @@ async function attemptAndValidate<TOutput, TValidated>(
682
822
  callContext: string,
683
823
  attempt: PromptAttemptInfo,
684
824
  options: ValidatedPromptRetryOptions<TOutput, TValidated>,
825
+ firstOutputTimeoutMs?: number,
685
826
  ): Promise<ValidatedPromptRetryResult<TOutput, TValidated>> {
686
827
  try {
687
828
  await attemptOnce(
@@ -693,6 +834,7 @@ async function attemptAndValidate<TOutput, TValidated>(
693
834
  attempt.label,
694
835
  options.transport,
695
836
  options.allowModelSuggestions,
837
+ firstOutputTimeoutMs,
696
838
  );
697
839
  } catch (error) {
698
840
  throw {
@@ -779,7 +921,9 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
779
921
  let firstError: unknown = null;
780
922
  let lastError: unknown = null;
781
923
  const cooledPrimary = primaryQuotaFailure(explicitPrimaryLabel);
924
+ const clock = createChainClock(options, timeoutMs);
782
925
 
926
+ const primaryStartedAt = Date.now();
783
927
  try {
784
928
  if (cooledPrimary)
785
929
  throw {
@@ -794,10 +938,11 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
794
938
  skipped: true,
795
939
  },
796
940
  } satisfies FailedAttempt;
941
+ const budget = clock.budget(0);
797
942
  return await attemptAndValidate(
798
943
  client,
799
944
  baseArgs,
800
- timeoutMs,
945
+ budget.timeoutMs,
801
946
  options.signal,
802
947
  callContext,
803
948
  {
@@ -808,10 +953,12 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
808
953
  model: baseBody.model,
809
954
  },
810
955
  options,
956
+ budget.firstOutputTimeoutMs,
811
957
  );
812
958
  } catch (caught) {
813
959
  const failure = caught as FailedAttempt;
814
960
  failedAttempts.push(failure);
961
+ clock.settle(failure.error, primaryStartedAt);
815
962
  firstError = failure.error;
816
963
  lastError = failure.error;
817
964
  if (
@@ -876,15 +1023,18 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
876
1023
  model: parsed,
877
1024
  };
878
1025
 
1026
+ const attemptStartedAt = Date.now();
879
1027
  try {
1028
+ const budget = clock.budget(i + 1);
880
1029
  const result = await attemptAndValidate(
881
1030
  client,
882
1031
  attemptArgs,
883
- timeoutMs,
1032
+ budget.timeoutMs,
884
1033
  options.signal,
885
1034
  callContext,
886
1035
  attempt,
887
1036
  options,
1037
+ budget.firstOutputTimeoutMs,
888
1038
  );
889
1039
  log(
890
1040
  `[${callContext}] fallback succeeded with ${label} (attempt ${i + 2}/${fallbacks.length + 1})`,
@@ -893,6 +1043,7 @@ export async function promptSyncWithValidatedOutputRetry<TOutput, TValidated = T
893
1043
  } catch (caught) {
894
1044
  const failure = caught as FailedAttempt;
895
1045
  failedAttempts.push(failure);
1046
+ clock.settle(failure.error, attemptStartedAt);
896
1047
  lastError = failure.error;
897
1048
  if (isNonRetryable(failure.error, options.signal)) {
898
1049
  throwWithPromptFailure(
@@ -645,3 +645,110 @@ test("session errors belonging to another child do not fail the run", async () =
645
645
  await promptAsyncAndWaitForIdle(host.client, request(), { pollIntervalMs: 1 });
646
646
  expect(host.polls()).toBe(1);
647
647
  });
648
+
649
+ describe("prompt_async transport and the first-output deadline", () => {
650
+ /**
651
+ * A child session where each model answers its own way. "dead" leaves the
652
+ * empty assistant row the host writes before the provider responds and stays
653
+ * busy forever; "partial" streams a text part and then stays busy; "answer"
654
+ * settles with a final assistant message on the next poll.
655
+ */
656
+ function modelHost(behaviours: Record<string, "dead" | "partial" | "answer">) {
657
+ const messages: Message[] = [];
658
+ let current: "dead" | "partial" | "answer" | null = null;
659
+ const sent: string[] = [];
660
+ const promptAsync = mock(
661
+ async (request: { body: { model?: { providerID: string; modelID: string } } }) => {
662
+ const model = request.body.model
663
+ ? `${request.body.model.providerID}/${request.body.model.modelID}`
664
+ : "primary";
665
+ sent.push(model);
666
+ current = behaviours[model] ?? "answer";
667
+ messages.push({
668
+ info: { id: `msg_user_${messages.length}`, role: "user", time: { created: 1 } },
669
+ parts: [{ type: "text", text: "go" }],
670
+ });
671
+ messages.push({
672
+ info: {
673
+ id: `msg_assistant_${messages.length}`,
674
+ role: "assistant",
675
+ time: { created: 2 },
676
+ },
677
+ parts: current === "partial" ? [{ type: "text", text: "<classify>" }] : [],
678
+ });
679
+ return { data: undefined };
680
+ },
681
+ );
682
+ const abort = mock(async () => {
683
+ current = null;
684
+ return { data: true };
685
+ });
686
+ const status = mock(async () => {
687
+ if (current === "dead" || current === "partial")
688
+ return { data: { "ses-child": { type: "busy" } } };
689
+ if (current === "answer") {
690
+ const last = messages.at(-1);
691
+ if (last && !last.info.time.completed) {
692
+ last.info.time.completed = 3;
693
+ last.info.finish = "stop";
694
+ last.parts = [{ type: "text", text: "<classify></classify>" }];
695
+ }
696
+ }
697
+ return { data: {} };
698
+ });
699
+ const list = mock(async () => ({ data: messages.map((message) => ({ ...message })) }));
700
+ return {
701
+ client: { session: { promptAsync, abort, status, messages: list } } as never,
702
+ sent,
703
+ abort,
704
+ };
705
+ }
706
+
707
+ async function runChain(
708
+ host: ReturnType<typeof modelHost>,
709
+ timeoutMs: number,
710
+ firstOutputTimeoutMs: number,
711
+ ) {
712
+ const transport = createPromptAsyncTransport(host.client, "ses-child", {
713
+ pollIntervalMs: 5,
714
+ });
715
+ return promptSyncWithValidatedOutputRetry(
716
+ host.client,
717
+ {
718
+ ...request(),
719
+ body: {
720
+ model: { providerID: "opencode", modelID: "free-model" },
721
+ parts: [{ type: "text", text: "go" }],
722
+ },
723
+ },
724
+ {
725
+ transport,
726
+ timeoutMs,
727
+ firstOutputTimeoutMs,
728
+ fallbackModels: ["anthropic/claude-sonnet-5-5"],
729
+ fetchOutput: async () => "<classify></classify>",
730
+ validateOutput: (output: string) => output,
731
+ },
732
+ );
733
+ }
734
+
735
+ test("a model that never answers is cut at the deadline and the fallback completes", async () => {
736
+ const host = modelHost({ "opencode/free-model": "dead" });
737
+ const startedAt = Date.now();
738
+
739
+ const run = await runChain(host, 5_000, 60);
740
+
741
+ expect(run.attempt.label).toBe("anthropic/claude-sonnet-5-5");
742
+ expect(host.sent).toEqual(["opencode/free-model", "anthropic/claude-sonnet-5-5"]);
743
+ // The dead model's run is stopped on the host before the fallback is sent.
744
+ expect(host.abort).toHaveBeenCalledWith({ path: { id: "ses-child" } });
745
+ expect(Date.now() - startedAt).toBeLessThan(2_000);
746
+ });
747
+
748
+ test("a model that streamed part of an answer keeps its timeout and does not fall back", async () => {
749
+ const host = modelHost({ "opencode/free-model": "partial" });
750
+
751
+ await expect(runChain(host, 200, 40)).rejects.toThrow("prompt timed out after 200ms");
752
+ expect(host.sent).toEqual(["opencode/free-model"]);
753
+ });
754
+ });
@@ -11,6 +11,7 @@ import { sumTokensFromChildMessages } from "../features/magic-context/subagent-t
11
11
  import { describeAssistantError } from "./assistant-message-extractor";
12
12
  import { sessionLog } from "./logger";
13
13
  import type { PromptArgs, PromptTransport } from "./model-suggestion-retry";
14
+ import { noteModelActivityMonitored, noteModelOutputReceived } from "./model-suggestion-retry";
14
15
 
15
16
  type Client = ReturnType<typeof createOpencodeClient> | undefined;
16
17
 
@@ -202,6 +203,29 @@ function isTerminalAssistant(message: unknown): boolean {
202
203
  );
203
204
  }
204
205
 
206
+ /**
207
+ * True when an assistant row shows the model has answered: any token usage, or a
208
+ * non-empty text or reasoning part, or a tool call. A row the host creates before
209
+ * the provider responds carries none of these.
210
+ */
211
+ function showsModelOutput(message: unknown): boolean {
212
+ if (infoOf(message).role !== "assistant") return false;
213
+ const tokens = sumTokensFromChildMessages([message]);
214
+ if (tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite > 0) return true;
215
+ const parts = (message as { parts?: unknown } | null)?.parts;
216
+ if (!Array.isArray(parts)) return false;
217
+ return parts.some((part) => {
218
+ if (!part || typeof part !== "object") return false;
219
+ const { type, text } = part as { type?: unknown; text?: unknown };
220
+ if (type === "tool") return true;
221
+ return (
222
+ (type === "text" || type === "reasoning") &&
223
+ typeof text === "string" &&
224
+ text.trim().length > 0
225
+ );
226
+ });
227
+ }
228
+
205
229
  function unwrapData(response: unknown): unknown {
206
230
  if (response && typeof response === "object" && "data" in response) {
207
231
  return (response as { data?: unknown }).data;
@@ -324,6 +348,10 @@ export async function promptAsyncAndWaitForIdle(
324
348
  }
325
349
  let sentAt = Date.now();
326
350
  let sawBusy = false;
351
+ // From here on this wait reports whether the model has produced anything,
352
+ // which lets the model chain tell a silent model from a slow answer.
353
+ noteModelActivityMonitored(signal);
354
+ let sawOutput = false;
327
355
 
328
356
  for (;;) {
329
357
  await sleep(pollIntervalMs, signal);
@@ -341,6 +369,17 @@ export async function promptAsyncAndWaitForIdle(
341
369
  }
342
370
  const status = await readStatus(session, sessionId, dir, signal);
343
371
  const messages = await readMessages(session, sessionId, dir, signal);
372
+ if (
373
+ !sawOutput &&
374
+ messages.some((message) => {
375
+ const id = messageId(message);
376
+ // Rows from before this send belong to an earlier attempt.
377
+ return id !== null && !initialBaseline.has(id) && showsModelOutput(message);
378
+ })
379
+ ) {
380
+ sawOutput = true;
381
+ noteModelOutputReceived(signal);
382
+ }
344
383
  if (budget) {
345
384
  const latest = messages.at(-1);
346
385
  const latestId = messageId(latest);