@bitkyc08/opencodex 2.87.0 → 2.88.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 (68) hide show
  1. package/gui/dist/assets/{App-ZfYbLHkB.js → App-CHC89EXQ.js} +3 -3
  2. package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-B5bC7AWL.js} +1 -1
  3. package/gui/dist/assets/index-B5bsvxor.js +94 -0
  4. package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-D8y2wTsB.js} +1 -1
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-rejection-memo.ts +71 -0
  8. package/src/adapters/anthropic-replay.ts +135 -0
  9. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  10. package/src/adapters/anthropic.ts +76 -45
  11. package/src/adapters/google-progress-history.ts +53 -0
  12. package/src/adapters/google.ts +17 -1
  13. package/src/bridge/internal.ts +6 -2
  14. package/src/bridge/response-json.ts +2 -2
  15. package/src/bridge/sse.ts +7 -6
  16. package/src/claude/inbound-content-options.ts +66 -0
  17. package/src/claude/outbound.ts +13 -5
  18. package/src/cli/cross-home-owner.ts +16 -2
  19. package/src/cli/loopback-family.ts +31 -0
  20. package/src/codex/catalog/routed-gather.ts +3 -1
  21. package/src/codex/websocket-activity.ts +161 -0
  22. package/src/codex/websocket-registry.ts +25 -1
  23. package/src/config/opencode-zen-upgrade.ts +9 -8
  24. package/src/generated/compatibility-version.json +88 -52
  25. package/src/images/loop.ts +72 -21
  26. package/src/lib/request-failure-model.ts +2 -0
  27. package/src/oauth/generic-account-failover.ts +7 -4
  28. package/src/protocols/encoders/adapter-events.ts +5 -5
  29. package/src/protocols/encoders/messages.ts +2 -2
  30. package/src/providers/opencode-zen-rate-limit.ts +7 -2
  31. package/src/responses/reasoning-replay-cache.ts +50 -4
  32. package/src/routing/analytics.ts +7 -15
  33. package/src/server/claude-messages.ts +2 -1
  34. package/src/server/index/live-sideband.ts +3 -2
  35. package/src/server/index/serve-options.ts +11 -5
  36. package/src/server/index/websocket-handler.ts +18 -4
  37. package/src/server/index.ts +7 -7
  38. package/src/server/lifecycle.ts +13 -1
  39. package/src/server/messages-native.ts +16 -3
  40. package/src/server/responses/adapter-continuation.ts +34 -18
  41. package/src/server/responses/adapter-delivery.ts +53 -6
  42. package/src/server/responses/adapter-dispatch.ts +27 -1
  43. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  44. package/src/server/responses/combo-stream-preflight.ts +3 -2
  45. package/src/server/responses/compaction-recovery.ts +2 -0
  46. package/src/server/responses/core-codex-account.ts +2 -2
  47. package/src/server/responses/core-combo.ts +3 -2
  48. package/src/server/responses/core-lifetime.ts +3 -2
  49. package/src/server/responses/core-options.ts +2 -0
  50. package/src/server/responses/core-replay.ts +5 -1
  51. package/src/server/responses/passthrough-execution.ts +3 -2
  52. package/src/server/responses/request-prepare.ts +1 -0
  53. package/src/server/responses/response-effects.ts +1 -0
  54. package/src/server/responses/response-tool-ownership.ts +21 -0
  55. package/src/server/responses/sidecar-execution.ts +43 -7
  56. package/src/server/responses/sidecar-send-budget.ts +2 -2
  57. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  58. package/src/server/ws-bridge.ts +2 -0
  59. package/src/types/opencode-zen.ts +9 -3
  60. package/src/types/request.ts +10 -2
  61. package/src/types/wire.ts +4 -2
  62. package/src/types.ts +1 -0
  63. package/src/usage/summary.ts +9 -3
  64. package/src/usage/telemetry-contract.ts +1 -0
  65. package/src/web-search/anthropic-history.ts +59 -0
  66. package/src/web-search/loop.ts +181 -68
  67. package/src/web-search/progress-stream.ts +11 -1
  68. package/gui/dist/assets/index-CfYDMj34.js +0 -94
@@ -1,3 +1,8 @@
1
+ import { bindAnthropicRejectionResponse } from "../adapters/anthropic-rejection-memo";
2
+ import { anthropicDeclaredTools } from "../adapters/anthropic-tool-catalog";
3
+ import { enableAnthropicReplayCapture, takeAnthropicReplay, bindAnthropicReplay, type AnthropicReplaySnapshot } from "../adapters/anthropic-replay";
4
+ import { anthropicSearchAssistantContent } from "./anthropic-history";
5
+ import type { createAnthropicThinkingRecovery } from "../server/responses/anthropic-thinking-recovery";
1
6
  import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base";
2
7
  import type { AdapterEvent, OcxConfig, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxProviderOpaqueToolCallMetadata, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types";
3
8
  import { namespacedToolName, toolChoiceToolPredicate } from "../types";
@@ -18,7 +23,7 @@ import type { WebSearchBackendId } from "./index";
18
23
  import { pacingHeaderDeadline } from "./header-deadline";
19
24
  import { redactSecretString } from "../lib/redact";
20
25
  import { readBoundedResponseBody } from "../lib/bounded-body";
21
- import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
26
+ import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry";
22
27
  import { rateLimitRetryDelayMs } from "../providers/key-failover";
23
28
  import {
24
29
  isTranslatorBudgetExceededError,
@@ -26,7 +31,7 @@ import {
26
31
  TranslatorBudgetExceededError,
27
32
  } from "../lib/translator-budget";
28
33
  import { formatWebSearchResults } from "./format-result";
29
- import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "./progress-stream";
34
+ import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError, WebSearchAdapterEventError } from "./progress-stream";
30
35
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
31
36
 
32
37
  const SSE_HEADERS = {
@@ -204,6 +209,9 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
204
209
  };
205
210
 
206
211
  for (const e of events) {
212
+ // Match the bridge: consecutive signature snapshots update one block. The next
213
+ // semantic event (including an empty thinking-block start) closes that block.
214
+ if (signature !== undefined && e.type !== "thinking_signature" && e.type !== "heartbeat") flushVisible();
207
215
  if (e.type === "text_delta" && e.anthropicThinkingSignature) {
208
216
  flushVisible();
209
217
  flushRaw();
@@ -216,7 +224,6 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
216
224
  rawReasoning += e.text;
217
225
  } else if (e.type === "thinking_signature") {
218
226
  signature = e.signature;
219
- flushVisible();
220
227
  } else if (e.type === "redacted_thinking") {
221
228
  flushVisible();
222
229
  flushRaw();
@@ -234,8 +241,8 @@ function normalizeQuery(q: string): string {
234
241
  }
235
242
 
236
243
  /**
237
- * Transient developer-role nudge appended ONLY to the forced-answer pass's request (never the
238
- * persisted `messages`). It tells the model to ground its final answer in the web results already
244
+ * Developer nudge for the forced-answer pass. Anthropic keeps its first sent position in the
245
+ * request-local history; other providers append it only for that iteration. It tells the model to ground its final answer in the web results already
239
246
  * gathered this turn. Citation wording is conditional — a failed/empty search still wants an answer,
240
247
  * just without fabricated sources.
241
248
  */
@@ -252,10 +259,8 @@ function forcedAnswerNudge(): OcxMessage {
252
259
  }
253
260
 
254
261
  /**
255
- * Transient developer-role nudge for the ONE recovery pass after a forced answer came back empty.
256
- * The recovery also removes every tool, so the model has nothing to call and can only return text;
257
- * this turn says so explicitly rather than relying on the removal alone. Like {@link forcedAnswerNudge}
258
- * it is iteration-local and never touches the persisted `messages`.
262
+ * Developer nudge for the ONE recovery pass after an empty forced answer. Tool calls are disabled;
263
+ * Anthropic retains its declared catalog and this nudge in request-local history for prefix fidelity.
259
264
  */
260
265
  function forcedAnswerRetryNudge(): OcxMessage {
261
266
  return {
@@ -269,17 +274,17 @@ function forcedAnswerRetryNudge(): OcxMessage {
269
274
  };
270
275
  }
271
276
 
272
- function jsonError(status: number, message: string): Response {
273
- return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), {
277
+ function jsonError(status: number, message: string, event?: Extract<AdapterEvent, { type: "error" }>): Response {
278
+ return new Response(JSON.stringify({ error: { message, type: event?.errorType ?? "upstream_error", code: event?.code ?? null, ...(event?.retryable === false ? { retryable: false } : {}) } }), {
274
279
  status,
275
- headers: { "Content-Type": "application/json" },
280
+ headers: { "Content-Type": "application/json", ...(event?.retryable === false ? { "x-should-retry": "false" } : {}) },
276
281
  });
277
282
  }
278
283
 
279
284
  /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a
280
285
  * non-200 jsonError; later (already-streaming) iterations surface it as an in-stream error event. */
281
286
  class LoopError extends Error {
282
- constructor(readonly status: number, message: string) {
287
+ constructor(readonly status: number, message: string, readonly event?: Extract<AdapterEvent, { type: "error" }>) {
283
288
  super(message);
284
289
  this.name = "LoopError";
285
290
  }
@@ -351,7 +356,8 @@ export interface WebSearchLoopDeps {
351
356
  /** Observe the exact adapter request selected for each routed-model iteration. */
352
357
  onRequestBuilt?: (request: AdapterRequest) => void;
353
358
  /** Request-scoped executor retains the core's selection binding across loop retries. */
354
- fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter) => typeof globalThis.fetch;
359
+ thinkingRecovery?: ReturnType<typeof createAnthropicThinkingRecovery>;
360
+ fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter, recovery?: AttemptRecoveryKind) => typeof globalThis.fetch;
355
361
  /** Called before each routed-model dispatch in the loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */
356
362
  onAttemptSend?: (recovery?: AttemptRecoveryKind) => void;
357
363
  /**
@@ -412,8 +418,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
412
418
  : 300_000;
413
419
 
414
420
  const messages: OcxMessage[] = [...parsed.context.messages];
421
+ const replaySnapshots = new Set<AnthropicReplaySnapshot>();
415
422
  const loopT0 = Date.now();
416
- const allTools = parsed.context.tools ?? [];
423
+ const allTools = adapter.name === "anthropic" ? anthropicDeclaredTools(parsed) : parsed.context.tools ?? [];
424
+ let forcedNudgeAdded = false;
425
+ let emptyNudgeAdded = false;
417
426
  let searchesExecuted = 0;
418
427
  let executedSearchCount = 0;
419
428
  // Queries whose search already failed this turn — repeats are short-circuited so a model that keeps
@@ -448,8 +457,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
448
457
  interface IterationResponse {
449
458
  response: Response;
450
459
  responseAdapter: ProviderAdapter;
460
+ recover?: (error: WebSearchAdapterEventError) => Promise<IterationResponse | undefined>;
451
461
  }
452
462
  type IterationSplit = ReturnType<typeof scanEventsForWebSearch> & {
463
+ events: AdapterEvent[];
464
+ anthropicReplay?: AnthropicReplaySnapshot;
453
465
  /**
454
466
  * How many leading passthrough events were already delivered live this iteration. They are
455
467
  * exactly the first N passthrough entries (live delivery stops before the first event that
@@ -464,6 +476,38 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
464
476
  const rateLimitRetryPolicy = deps.retryOn429Policy ?? null;
465
477
  let rateLimitRetries = 0;
466
478
 
479
+ const requireSuccessfulHeaders = async (prepared: IterationResponse): Promise<void> => {
480
+ if (!prepared.response.ok) {
481
+ if (deps.thinkingRecovery && prepared.responseAdapter.name === "anthropic") {
482
+ const event = await deps.thinkingRecovery.projectHttpFailure(prepared.response, signal);
483
+ throw new LoopError(event.status ?? prepared.response.status, event.message, event);
484
+ }
485
+ let body: Awaited<ReturnType<typeof readBoundedResponseBody>>;
486
+ try {
487
+ body = await readBoundedResponseBody(prepared.response, { signal });
488
+ } catch {
489
+ // The response status is authoritative even when its untrusted error body fails while
490
+ // being read (including a synchronous getReader() failure). Never route that failure
491
+ // through the adapter formatter or the generic transport error, which could expose its
492
+ // raw message. A parent/client cancellation still owns the request lifecycle as 499.
493
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
494
+ throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`);
495
+ }
496
+ let formatted = "";
497
+ if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) {
498
+ try {
499
+ formatted = prepared.responseAdapter.formatErrorBody(
500
+ prepared.response.status,
501
+ prepared.response.headers,
502
+ body.text,
503
+ ).trim();
504
+ } catch { /* formatter hooks are best-effort; unsafe raw text is never the fallback */ }
505
+ }
506
+ const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
507
+ throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`);
508
+ }
509
+ };
510
+
467
511
  // Acquire one iteration's final response headers. The first call is drained eagerly so an initial
468
512
  // connect/header/HTTP failure stays a non-2xx JSON response. Its successful BODY is deliberately
469
513
  // left unread until the downstream Responses SSE bridge exists.
@@ -473,24 +517,30 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
473
517
  * restart) before the `on429` key rotation.
474
518
  */
475
519
  const prepareIterationEvents = async function* (forceAnswer: boolean): AsyncGenerator<AdapterEvent, IterationResponse> {
476
- // On the forced-answer pass the model is asked to answer from the results already in
477
- // `messages`. A weak model can still produce a thin answer that
478
- // ignores what the search found, which reads to the user as "the search did nothing". Nudge it
479
- // (iteration-locally — never mutate the shared `messages`) to actually use the gathered results.
480
- // Only when a REAL search ran (executedSearchCount, not empty-query/limit/repeat placeholders).
481
- let iterMessages: OcxMessage[] = forceAnswer && executedSearchCount > 0
482
- ? [...messages, forcedAnswerNudge()]
483
- : messages;
484
- // #1001 follow-up: the recovery pass for an empty forced answer. Removing every tool leaves the
485
- // model nothing to call, and the extra developer turn asks it for the text it just failed to
486
- // produce. `toolChoice: "none"` is what drops those definitions in the adapter, so the retry
487
- // cannot repeat the same empty or tool-shaped response.
488
520
  const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0;
489
- if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()];
521
+ const preserveAnthropicPrefix = adapter.name === "anthropic";
522
+ let iterMessages = messages;
523
+ if (preserveAnthropicPrefix) {
524
+ // Once sent, each nudge is part of subsequent signatures' bound prefix. Keep its
525
+ // original position even when a forced-answer response makes another tool call.
526
+ if (forceAnswer && executedSearchCount > 0 && !forcedNudgeAdded) {
527
+ const nudge = forcedAnswerNudge();
528
+ if (nudge.role === "developer" && typeof nudge.content === "string") nudge.content = [{ type: "text", text: nudge.content }];
529
+ messages.push(nudge); forcedNudgeAdded = true;
530
+ }
531
+ if (recoveringEmptyAnswer && !emptyNudgeAdded) {
532
+ const nudge = forcedAnswerRetryNudge();
533
+ if (nudge.role === "developer" && typeof nudge.content === "string") nudge.content = [{ type: "text", text: nudge.content }];
534
+ messages.push(nudge); emptyNudgeAdded = true;
535
+ }
536
+ } else {
537
+ if (forceAnswer && executedSearchCount > 0) iterMessages = [...messages, forcedAnswerNudge()];
538
+ if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()];
539
+ }
490
540
  const iterParsed: OcxParsedRequest = {
491
541
  ...parsed, stream: true,
492
542
  ...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}),
493
- context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : allTools },
543
+ context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer && !preserveAnthropicPrefix ? [] : allTools },
494
544
  };
495
545
  // One cumulative header deadline spans key rotations and reset sends, excluding local pacing.
496
546
  // clear() stops only its timer after final headers; the direct turn signal remains attached to
@@ -528,7 +578,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
528
578
  cachedRequest = request;
529
579
  cachedAdapter = requestAdapter;
530
580
  }
531
- const requestFetch = headerDeadline.pacedFetch(deps.fetchForRequest?.(request, iterParsed, requestAdapter) ?? routedProviderFetch);
581
+ const requestFetch = headerDeadline.pacedFetch(deps.fetchForRequest?.(request, iterParsed, requestAdapter, recovery) ?? routedProviderFetch);
532
582
  let response: Response;
533
583
  try {
534
584
  if (requestAdapter.fetchResponse) {
@@ -571,9 +621,33 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
571
621
  } finally {
572
622
  request.releaseBodyObservation?.();
573
623
  }
624
+ bindAnthropicRejectionResponse(request, response);
574
625
  return { response, responseAdapter: requestAdapter };
575
626
  };
576
627
 
628
+ const recover = async (error: WebSearchAdapterEventError): Promise<IterationResponse | undefined> => {
629
+ if (signal.aborted || !error.replaySafe || isNonReplayableResponse(prepared.response)
630
+ || !deps.thinkingRecovery?.prepareEvent(error.event.message, prepared.responseAdapter.name, iterParsed, prepared.response)) return;
631
+ cachedRequest = undefined;
632
+ cachedAdapter = undefined;
633
+ // The successful headers ended the previous timer; the replacement owns a fresh deadline.
634
+ headerDeadline = pacingHeaderDeadline(connectTimeoutMs, signal);
635
+ try {
636
+ const replacement = await fetchOnce(prepared.responseAdapter, "opaque-blob-rejection");
637
+ headerDeadline.clear();
638
+ await requireSuccessfulHeaders(replacement);
639
+ return replacement;
640
+ } catch (error) {
641
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
642
+ if (headerDeadline.didExpire()) throw new LoopError(504,
643
+ `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`);
644
+ if (error instanceof LoopError || error instanceof SendBudgetExhaustedError || isTranslatorBudgetExceededError(error)) throw error;
645
+ throw new LoopError(502, `Provider unreachable: ${error instanceof Error ? error.message : String(error)}`);
646
+ } finally {
647
+ headerDeadline.clear();
648
+ deps.onIterationEnd?.();
649
+ }
650
+ };
577
651
  let prepared = await fetchOnce(adapter);
578
652
  // Same-target 429 wait-and-retry (opt-in `retryOn429`) BEFORE key rotation: a primary-key
579
653
  // rate-limit blip replays on the SAME key; rotation only runs after attempts exhaust.
@@ -644,36 +718,20 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
644
718
  prepared = replacement;
645
719
  }
646
720
 
721
+ if (await deps.thinkingRecovery?.prepareHttp(prepared.response, prepared.responseAdapter.name, iterParsed)) {
722
+ try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* closed */ }
723
+ cachedRequest = undefined;
724
+ cachedAdapter = undefined;
725
+ prepared = await fetchOnce(adapter, "opaque-blob-rejection");
726
+ }
727
+
728
+
647
729
  // Final headers have arrived. Clear only the deadline timer before ANY body read.
648
730
  headerDeadline.clear();
649
- if (!prepared.response.ok) {
650
- let body: Awaited<ReturnType<typeof readBoundedResponseBody>>;
651
- try {
652
- body = await readBoundedResponseBody(prepared.response, { signal });
653
- } catch {
654
- // The response status is authoritative even when its untrusted error body fails while
655
- // being read (including a synchronous getReader() failure). Never route that failure
656
- // through the adapter formatter or the generic transport error, which could expose its
657
- // raw message. A parent/client cancellation still owns the request lifecycle as 499.
658
- if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
659
- throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`);
660
- }
661
- let formatted = "";
662
- if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) {
663
- try {
664
- formatted = prepared.responseAdapter.formatErrorBody(
665
- prepared.response.status,
666
- prepared.response.headers,
667
- body.text,
668
- ).trim();
669
- } catch { /* formatter hooks are best-effort; unsafe raw text is never the fallback */ }
670
- }
671
- const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
672
- throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`);
673
- }
674
- return prepared;
731
+ await requireSuccessfulHeaders(prepared);
732
+ return { ...prepared, recover };
675
733
  } catch (error) {
676
- if (isTranslatorBudgetExceededError(error)) throw error;
734
+ if (isTranslatorBudgetExceededError(error) || error instanceof SendBudgetExhaustedError) throw error;
677
735
  if (headerDeadline.didExpire()) {
678
736
  throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`);
679
737
  }
@@ -710,15 +768,25 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
710
768
  const events: AdapterEvent[] = [];
711
769
  let liveWindowOpen = deps.streamRoutedModelOutput === true;
712
770
  let streamedPassthroughCount = 0;
771
+ let anthropicReplay: AnthropicReplaySnapshot | undefined;
772
+ let releaseCapture = () => {};
713
773
  try {
714
- const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter);
774
+ const parse: ProviderAdapter["parseStream"] = (response, budget) => {
775
+ if (prepared.responseAdapter.name === "anthropic") releaseCapture = enableAnthropicReplayCapture(response);
776
+ return prepared.responseAdapter.parseStream(response, budget);
777
+ };
715
778
  for await (const event of parseStreamWithProgress(prepared.response, parse, {
716
779
  signal,
717
780
  inactivityTimeoutMs: routedModelStallTimeoutMs,
718
781
  translatorBudget,
782
+ preserveAdapterError: deps.thinkingRecovery !== undefined,
719
783
  // A pass that fails was still billed; the error path reports it with the hidden passes.
720
784
  onErrorUsage: usage => { hiddenUsage = mergeUsage(hiddenUsage, usage); },
721
785
  })) {
786
+ if (event.type === "done") {
787
+ anthropicReplay = takeAnthropicReplay(event);
788
+ if (anthropicReplay) replaySnapshots.add(anthropicReplay);
789
+ }
722
790
  if (event.type === "heartbeat") yield event;
723
791
  // Kiro's explicit-completion protocol marks ordinary assistant text as commentary while
724
792
  // it performs a bounded final-answer retry. That text is safe to surface immediately and
@@ -740,12 +808,21 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
740
808
  }
741
809
  }
742
810
  } catch (error) {
743
- if (isTranslatorBudgetExceededError(error)) throw error;
811
+ if (isTranslatorBudgetExceededError(error) || error instanceof SendBudgetExhaustedError) throw error;
744
812
  if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
813
+ if (error instanceof WebSearchAdapterEventError) {
814
+ const replacement = await prepared.recover?.(error);
815
+ if (replacement) return yield* consumeIterationEvents(replacement);
816
+ const event = isNonReplayableResponse(prepared.response)
817
+ ? { ...error.event, message: "Provider stream error: upstream diagnostic withheld for a non-replayable failure", retryable: false as const }
818
+ : error.event;
819
+ throw new LoopError(event.status ?? 502, event.message, event);
820
+ }
821
+ if (error instanceof LoopError) throw error;
745
822
  if (error instanceof RoutedModelInactivityError) throw new LoopError(504, error.message);
746
823
  if (error instanceof WebSearchStreamProtocolError) throw new LoopError(502, error.message);
747
824
  throw new LoopError(502, `Provider stream error: ${error instanceof Error ? error.message : String(error)}`);
748
- }
825
+ } finally { releaseCapture(); }
749
826
 
750
827
  const terminalIndexes = events.flatMap((event, index) =>
751
828
  event.type === "done" || event.type === "incomplete" || event.type === "error" ? [index] : []);
@@ -759,7 +836,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
759
836
  }
760
837
  throw new LoopError(502, terminal.message);
761
838
  }
762
- return { ...scanEventsForWebSearch(events), streamedPassthroughCount };
839
+ deps.thinkingRecovery?.observeIteration(events);
840
+ return { ...scanEventsForWebSearch(events), streamedPassthroughCount, events, anthropicReplay };
763
841
  };
764
842
 
765
843
  // Execute one model-requested web_search call. The call may batch several queries (native
@@ -768,7 +846,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
768
846
  // valid, and surface as ONE search cell carrying every attempted query. A real search (one that
769
847
  // hits the sidecar) shows the spinner WHILE the batch runs. Empty/limit/repeat placeholders never
770
848
  // emit a cell (matching the prior single-query behavior).
771
- async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = []): AsyncGenerator<AdapterEvent> {
849
+ async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = [], appendAssistant = true): AsyncGenerator<AdapterEvent> {
772
850
  const results: { query: string; outcome: SidecarOutcome }[] = [];
773
851
  let beganCell = false;
774
852
  if (call.queries.length === 0) {
@@ -845,7 +923,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
845
923
  const callArgs: Record<string, unknown> = call.queries.length > 1
846
924
  ? { queries: call.queries }
847
925
  : { query: call.queries[0] ?? "" };
848
- messages.push({
926
+ if (appendAssistant) messages.push({
849
927
  role: "assistant",
850
928
  content: [
851
929
  // Signed thinking must precede tool_use on replay (Anthropic extended thinking), and
@@ -904,7 +982,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
904
982
  firstPrepared = await prepareIterationDrained(false);
905
983
  } catch (e) {
906
984
  if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
907
- if (e instanceof LoopError) return jsonError(e.status, e.message);
985
+ if (e instanceof LoopError) return jsonError(e.status, e.message, e.event);
986
+ if (e instanceof SendBudgetExhaustedError) return jsonError(429, e.message,
987
+ { type: "error", message: e.message, errorType: "rate_limit_error", code: e.code, retryable: false });
908
988
  throw e;
909
989
  }
910
990
 
@@ -987,7 +1067,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
987
1067
  hiddenUsage = mergeUsage(hiddenUsage, terminalEvent.usage);
988
1068
  if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) {
989
1069
  emptyAnswerRetries++;
990
- console.warn("[web-search-loop] empty forced answer — retrying once without tools");
1070
+ console.warn("[web-search-loop] empty forced answer — retrying once with tool calls disabled");
991
1071
  yield { type: "heartbeat" };
992
1072
  continue;
993
1073
  }
@@ -1012,10 +1092,36 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
1012
1092
  for (const event of split.passthrough.slice(split.streamedPassthroughCount)) {
1013
1093
  if (event.type === "text_delta" && event.anthropicThinkingSignature) yield event;
1014
1094
  }
1015
- const iterationThinking = extractIterationThinking(split.passthrough);
1095
+ const preserveAnthropicHistory = prepared.responseAdapter.name === "anthropic";
1096
+ if (preserveAnthropicHistory) {
1097
+ if (!split.anthropicReplay) throw new LoopError(502, "Anthropic search history cannot be replayed losslessly");
1098
+ const message = { role: "assistant" as const,
1099
+ content: anthropicSearchAssistantContent(split.events), timestamp: Date.now() };
1100
+ const originalCalls = split.anthropicReplay.content.filter(block => block.type === "tool_use");
1101
+ const priorIds = new Set(messages.flatMap(message => message.role === "assistant"
1102
+ ? message.content.filter(part => part.type === "toolCall").map(part => part.id)
1103
+ : message.role === "toolResult" ? [message.toolCallId] : []));
1104
+ if (originalCalls.some(block => priorIds.has(block.id as string))) {
1105
+ throw new LoopError(502, "Anthropic search replay tool identity changed");
1106
+ }
1107
+ const calls = message.content.filter(part => part.type === "toolCall");
1108
+ if (originalCalls.length !== calls.length || originalCalls.some((block, index) => block.id !== calls[index]?.id)) {
1109
+ throw new LoopError(502, "Anthropic search replay tool identity changed");
1110
+ }
1111
+ // The raw input is authoritative, including non-empty content_block_start input.
1112
+ for (const [index, call] of calls.entries()) call.arguments = originalCalls[index]!.input as Record<string, unknown>;
1113
+ for (const call of split.calls) {
1114
+ const original = originalCalls.find(block => block.id === call.id);
1115
+ if (!original) throw new LoopError(502, "Anthropic search replay tool identity changed");
1116
+ call.queries = parseQueries(JSON.stringify(original.input));
1117
+ }
1118
+ bindAnthropicReplay(message, split.anthropicReplay);
1119
+ messages.push(message);
1120
+ }
1121
+ const iterationThinking = preserveAnthropicHistory ? [] : extractIterationThinking(split.passthrough);
1016
1122
  for (const [callIndex, call] of split.calls.entries()) {
1017
1123
  accountRefusalOutputStarted = true;
1018
- yield* runSearchCall(call, callIndex === 0 ? iterationThinking : []);
1124
+ yield* runSearchCall(call, callIndex === 0 ? iterationThinking : [], !preserveAnthropicHistory);
1019
1125
  }
1020
1126
  } catch (e) {
1021
1127
  // The bridge reports no usage for either error; the iterations already sent still count.
@@ -1029,7 +1135,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
1029
1135
  message: "upstream translation buffer exceeded the safe limit",
1030
1136
  };
1031
1137
  } else {
1032
- yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };
1138
+ if (e instanceof SendBudgetExhaustedError) yield { type: "error", status: 429,
1139
+ errorType: "rate_limit_error", code: e.code, message: e.message, retryable: false };
1140
+ else yield e instanceof LoopError && e.event ? e.event
1141
+ : e instanceof LoopError && deps.thinkingRecovery
1142
+ ? { type: "error", status: e.status, errorType: "upstream_error", message: e.message }
1143
+ : { type: "error", message: e instanceof Error ? e.message : String(e) };
1033
1144
  }
1034
1145
  return;
1035
1146
  }
@@ -1037,6 +1148,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
1037
1148
  if (hiddenUsage) deps.onUsage?.(hiddenUsage);
1038
1149
  yield { type: "error", message: "web-search loop exceeded its iteration cap" };
1039
1150
  } finally {
1151
+ for (const snapshot of replaySnapshots) snapshot.release();
1152
+ replaySnapshots.clear();
1040
1153
  if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
1041
1154
  }
1042
1155
  }
@@ -26,7 +26,15 @@ export class WebSearchStreamProtocolError extends Error {
26
26
  }
27
27
  }
28
28
 
29
+ /** Opt-in preserves an adapter refusal before it becomes an untyped parser exception. */
30
+ export class WebSearchAdapterEventError extends Error {
31
+ constructor(readonly event: Extract<AdapterEvent, { type: "error" }>, readonly replaySafe: boolean) {
32
+ super(event.message);
33
+ }
34
+ }
35
+
29
36
  export interface ParseStreamWithProgressOptions {
37
+ preserveAdapterError?: boolean;
30
38
  inactivityTimeoutMs: number;
31
39
  translatorBudget: TranslatorBudget;
32
40
  signal?: AbortSignal;
@@ -249,6 +257,7 @@ export async function* parseStreamWithProgress(
249
257
 
250
258
  resetInactivity();
251
259
 
260
+ let replaySafe = true;
252
261
  const parserPump = (async (): Promise<void> => {
253
262
  let heldTerminal: Extract<AdapterEvent, { type: "done" | "incomplete" }> | undefined;
254
263
  try {
@@ -301,9 +310,10 @@ export async function* parseStreamWithProgress(
301
310
  if (event.usage) options.onErrorUsage?.(event.usage);
302
311
  fail(event.code === "translation_buffer_limit"
303
312
  ? new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_TURN_BYTES)
304
- : new Error(event.message));
313
+ : options.preserveAdapterError ? new WebSearchAdapterEventError(event, replaySafe) : new Error(event.message));
305
314
  return;
306
315
  }
316
+ replaySafe &&= event.type === "heartbeat" && event.replayUnsafe !== true;
307
317
  if (event.type === "done" || event.type === "incomplete") {
308
318
  heldTerminal = event;
309
319
  // Response-byte inactivity ends once the adapter has produced a terminal event.