@bitkyc08/opencodex 2.87.0 → 2.89.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 (246) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/index-uchD-ngf.js +94 -0
  7. package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic-rejection-memo.ts +71 -0
  13. package/src/adapters/anthropic-replay.ts +135 -0
  14. package/src/adapters/anthropic-tool-catalog.ts +11 -0
  15. package/src/adapters/anthropic.ts +86 -48
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/google-http.ts +3 -2
  18. package/src/adapters/google-progress-history.ts +53 -0
  19. package/src/adapters/google-vertex-endpoint.ts +67 -0
  20. package/src/adapters/google.ts +52 -30
  21. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  22. package/src/adapters/openai-responses/passthrough.ts +17 -12
  23. package/src/adapters/openai-responses/reasoning.ts +68 -1
  24. package/src/adapters/registry.ts +1 -1
  25. package/src/adapters/upstream-http-error.ts +3 -2
  26. package/src/adapters/zed.ts +7 -4
  27. package/src/bridge/internal.ts +17 -3
  28. package/src/bridge/response-json.ts +4 -4
  29. package/src/bridge/sse.ts +17 -10
  30. package/src/claude/inbound-content-options.ts +66 -0
  31. package/src/claude/inbound.ts +12 -26
  32. package/src/claude/outbound.ts +18 -5
  33. package/src/cli/account-api.ts +13 -1
  34. package/src/cli/account-extended.ts +17 -5
  35. package/src/cli/account-key-quota.ts +4 -1
  36. package/src/cli/account.ts +1 -1
  37. package/src/cli/capabilities-agents-routing.ts +45 -8
  38. package/src/cli/capabilities-base.ts +5 -5
  39. package/src/cli/capabilities-provider-models.ts +5 -4
  40. package/src/cli/combo-result.ts +8 -2
  41. package/src/cli/combo.ts +17 -0
  42. package/src/cli/cross-home-owner.ts +16 -2
  43. package/src/cli/help.ts +1 -1
  44. package/src/cli/loopback-family.ts +31 -0
  45. package/src/cli/message-command.ts +4 -1
  46. package/src/cli/message-remote-args.ts +69 -0
  47. package/src/cli/message-remote-command.ts +77 -0
  48. package/src/cli/provider-runtime.ts +69 -1
  49. package/src/cli/registry.ts +3 -3
  50. package/src/codex/account-pause.ts +6 -1
  51. package/src/codex/account-runtime-state.ts +5 -0
  52. package/src/codex/account-store.ts +30 -3
  53. package/src/codex/auth-api/account-list.ts +3 -0
  54. package/src/codex/auth-api/main-account-probe.ts +3 -2
  55. package/src/codex/auth-context.ts +9 -4
  56. package/src/codex/catalog/aggregation.ts +15 -0
  57. package/src/codex/catalog/auto-review.ts +2 -1
  58. package/src/codex/catalog/build-entries.ts +17 -7
  59. package/src/codex/catalog/derive-entry.ts +3 -1
  60. package/src/codex/catalog/metadata.ts +2 -0
  61. package/src/codex/catalog/model-visibility.ts +0 -2
  62. package/src/codex/catalog/ownership.ts +71 -0
  63. package/src/codex/catalog/parsing.ts +12 -3
  64. package/src/codex/catalog/retained-sync.ts +2 -1
  65. package/src/codex/catalog/routed-gather.ts +3 -1
  66. package/src/codex/catalog/routed-removal.ts +4 -4
  67. package/src/codex/convergence.ts +2 -1
  68. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  69. package/src/codex/low-quota-protection.ts +57 -31
  70. package/src/codex/main-account-cache.ts +77 -3
  71. package/src/codex/main-account.ts +6 -2
  72. package/src/codex/native-residue.ts +2 -3
  73. package/src/codex/quota.ts +26 -2
  74. package/src/codex/websocket-activity.ts +161 -0
  75. package/src/codex/websocket-registry.ts +25 -1
  76. package/src/combos/failover.ts +2 -1
  77. package/src/combos/jev-model-backend.ts +30 -7
  78. package/src/combos/jev-model-request.ts +20 -0
  79. package/src/combos/jev-quota-activation.ts +153 -0
  80. package/src/combos/jev-quota-config.ts +62 -0
  81. package/src/combos/jev-quota-route.ts +26 -0
  82. package/src/combos/jev-quota.ts +88 -0
  83. package/src/combos/jev.ts +25 -12
  84. package/src/combos/types.ts +27 -0
  85. package/src/compatibility/index.ts +3 -0
  86. package/src/compatibility/openai-responses.ts +8 -1
  87. package/src/compatibility/opengateway-responses.ts +61 -0
  88. package/src/config/account-auto-pause-revisions.ts +136 -0
  89. package/src/config/diagnostics.ts +13 -0
  90. package/src/config/live-reconcile.ts +9 -0
  91. package/src/config/opencode-zen-upgrade.ts +9 -8
  92. package/src/config/persist-unlocked.ts +10 -1
  93. package/src/config/removed-opencode-free.ts +135 -0
  94. package/src/config/schema/account-auto-pause.ts +14 -0
  95. package/src/config/schema/config-schema.ts +15 -0
  96. package/src/config/schema/leaf-validators.ts +7 -0
  97. package/src/config.ts +10 -3
  98. package/src/generated/compatibility-version.json +411 -175
  99. package/src/images/loop.ts +101 -27
  100. package/src/lib/account-auto-pause-response.ts +16 -0
  101. package/src/lib/bun-runtime.ts +18 -1
  102. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  103. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  104. package/src/lib/decision-quota-sink.ts +76 -0
  105. package/src/lib/request-failure-model.ts +2 -0
  106. package/src/lib/state-store-registrations.ts +25 -0
  107. package/src/link/ssh-argv.ts +3 -0
  108. package/src/messaging/envelope.ts +6 -3
  109. package/src/messaging/remote-auth.ts +83 -0
  110. package/src/messaging/remote-bridge.ts +270 -0
  111. package/src/messaging/remote-contract.ts +108 -0
  112. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  113. package/src/messaging/remote-enrollment.ts +244 -0
  114. package/src/messaging/remote-envelope.ts +40 -0
  115. package/src/messaging/remote-files.ts +117 -0
  116. package/src/messaging/remote-owner.ts +169 -0
  117. package/src/messaging/remote-ports.ts +56 -0
  118. package/src/messaging/remote-process.ts +131 -0
  119. package/src/messaging/remote-replies.ts +98 -0
  120. package/src/messaging/remote-rpc-admission.ts +32 -0
  121. package/src/messaging/remote-send.ts +72 -0
  122. package/src/messaging/remote-store.ts +111 -0
  123. package/src/messaging/remote-tunnels.ts +65 -0
  124. package/src/messaging/rpc.ts +13 -1
  125. package/src/oauth/account-auto-pause-policy.ts +66 -0
  126. package/src/oauth/account-auto-pause.ts +171 -0
  127. package/src/oauth/account-quota-rank.ts +2 -1
  128. package/src/oauth/anthropic-account-refusal.ts +6 -0
  129. package/src/oauth/anthropic-model-family.ts +8 -0
  130. package/src/oauth/anthropic-model-quota.ts +2 -6
  131. package/src/oauth/api-key-pool-preserve.ts +37 -0
  132. package/src/oauth/generic-account-failover.ts +7 -4
  133. package/src/oauth/index.ts +5 -35
  134. package/src/oauth/store.ts +35 -8
  135. package/src/oauth/types.ts +2 -0
  136. package/src/plugins/upstream-hooks.ts +14 -1
  137. package/src/protocols/encoders/adapter-events.ts +5 -5
  138. package/src/protocols/encoders/messages.ts +2 -2
  139. package/src/providers/api-key-pause.ts +32 -0
  140. package/src/providers/api-key-resolve.ts +10 -4
  141. package/src/providers/api-key-selection.ts +6 -1
  142. package/src/providers/api-keys.ts +8 -4
  143. package/src/providers/claude-cli-usage.ts +5 -0
  144. package/src/providers/key-failover.ts +11 -11
  145. package/src/providers/model-rename-fields.ts +1 -0
  146. package/src/providers/opencode-zen-rate-limit.ts +12 -73
  147. package/src/providers/quota/account-cache.ts +15 -1
  148. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  149. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  150. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  151. package/src/providers/quota/antigravity.ts +39 -6
  152. package/src/providers/quota/report-cache.ts +14 -0
  153. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  154. package/src/providers/quota-decision-publication.ts +77 -0
  155. package/src/providers/quota-decision-snapshot.ts +145 -0
  156. package/src/providers/quota-routing-cache.ts +3 -0
  157. package/src/providers/quota-types.ts +6 -1
  158. package/src/providers/quota.ts +3 -2
  159. package/src/providers/registry/entries-extended.ts +8 -52
  160. package/src/providers/registry/model-seeds.ts +1 -1
  161. package/src/providers/registry.ts +1 -2
  162. package/src/providers/stale-vision-classification-migration.ts +1 -1
  163. package/src/responses/parser.ts +18 -10
  164. package/src/responses/reasoning-replay-cache.ts +50 -4
  165. package/src/responses/web-search-replay.ts +138 -0
  166. package/src/routing/analytics.ts +7 -15
  167. package/src/routing/compatibility/behavior.ts +4 -1
  168. package/src/server/auth-cors.ts +7 -0
  169. package/src/server/background-lifecycle.ts +2 -1
  170. package/src/server/chat-native.ts +28 -13
  171. package/src/server/claude-messages.ts +4 -2
  172. package/src/server/index/live-sideband.ts +3 -2
  173. package/src/server/index/optional-listeners.ts +6 -0
  174. package/src/server/index/serve-options.ts +11 -5
  175. package/src/server/index/websocket-handler.ts +18 -4
  176. package/src/server/index.ts +7 -7
  177. package/src/server/lifecycle.ts +13 -1
  178. package/src/server/management/combo-routes.ts +17 -2
  179. package/src/server/management/oauth-account-routes.ts +10 -1
  180. package/src/server/management/provider-completion-test.ts +289 -0
  181. package/src/server/management/provider-routes.ts +9 -1
  182. package/src/server/management/route-registry.ts +2 -0
  183. package/src/server/messages-native.ts +34 -7
  184. package/src/server/messages-shared.ts +4 -1
  185. package/src/server/request-log.ts +7 -2
  186. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  187. package/src/server/responses/adapter-continuation.ts +34 -18
  188. package/src/server/responses/adapter-delivery.ts +53 -6
  189. package/src/server/responses/adapter-dispatch.ts +104 -29
  190. package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
  191. package/src/server/responses/combo-session-recall.ts +25 -0
  192. package/src/server/responses/combo-stream-preflight.ts +6 -3
  193. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  194. package/src/server/responses/compact.ts +8 -5
  195. package/src/server/responses/compaction-recovery.ts +2 -0
  196. package/src/server/responses/context-overflow.ts +141 -2
  197. package/src/server/responses/core-codex-account.ts +9 -3
  198. package/src/server/responses/core-combo.ts +34 -7
  199. package/src/server/responses/core-lifetime.ts +3 -2
  200. package/src/server/responses/core-options.ts +2 -0
  201. package/src/server/responses/core-replay.ts +5 -1
  202. package/src/server/responses/core.ts +12 -9
  203. package/src/server/responses/empty-completion-guard.ts +2 -0
  204. package/src/server/responses/fetch-helpers.ts +35 -4
  205. package/src/server/responses/jev-model-invoke.ts +13 -14
  206. package/src/server/responses/native-steering-log.ts +2 -2
  207. package/src/server/responses/passthrough-delivery.ts +3 -2
  208. package/src/server/responses/passthrough-dispatch.ts +30 -33
  209. package/src/server/responses/passthrough-execution.ts +3 -2
  210. package/src/server/responses/passthrough-observations.ts +38 -0
  211. package/src/server/responses/request-compression.ts +98 -0
  212. package/src/server/responses/request-prepare.ts +1 -0
  213. package/src/server/responses/request-transport.ts +32 -3
  214. package/src/server/responses/response-effects.ts +1 -0
  215. package/src/server/responses/response-tool-ownership.ts +21 -0
  216. package/src/server/responses/run-turn-execution.ts +2 -0
  217. package/src/server/responses/sidecar-execution.ts +59 -10
  218. package/src/server/responses/sidecar-send-budget.ts +2 -2
  219. package/src/server/responses/terminal-guard.ts +2 -0
  220. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  221. package/src/server/startup-action-control.ts +2 -2
  222. package/src/server/startup-health-cache.ts +2 -2
  223. package/src/server/windows-tray-control.ts +2 -2
  224. package/src/server/ws-bridge.ts +2 -0
  225. package/src/service/definition.ts +9 -1
  226. package/src/types/account-auto-pause.ts +15 -0
  227. package/src/types/config.ts +23 -1
  228. package/src/types/opencode-zen.ts +9 -3
  229. package/src/types/provider.ts +9 -5
  230. package/src/types/request.ts +30 -4
  231. package/src/types/wire.ts +4 -2
  232. package/src/types.ts +2 -0
  233. package/src/usage/cost.ts +17 -1
  234. package/src/usage/expected-prices.ts +3 -1
  235. package/src/usage/jev-stats.ts +4 -0
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/summary.ts +16 -3
  238. package/src/usage/telemetry-contract.ts +1 -0
  239. package/src/web-search/anthropic-history.ts +59 -0
  240. package/src/web-search/loop.ts +212 -73
  241. package/src/web-search/progress-stream.ts +14 -1
  242. package/src/web-search/run-turn-loop.ts +9 -3
  243. package/src/web-search/sidecar-search-replay.ts +96 -0
  244. package/gui/dist/assets/App-ZfYbLHkB.js +0 -58
  245. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  246. 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";
@@ -9,6 +14,7 @@ import { bridgeToResponsesSSE } from "../bridge";
9
14
  import { isTerminalRefusalIncomplete } from "../bridge/terminal-refusal";
10
15
  import { guardEmptyToolCatalogStream, type ClientToolCatalogRestriction } from "../lib/empty-tool-catalog";
11
16
  import { mergeUsage } from "../server/responses/empty-completion-guard";
17
+ import { markSidecarContextOverflow, sidecarLoopTerminalEvent, upstreamErrorCode, type SidecarLoopFailure } from "../server/responses/context-overflow";
12
18
  import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor";
13
19
  import { runAnthropicWebSearch } from "./anthropic-executor";
14
20
  import { runXaiWebSearch, type XaiSearchOptions } from "./xai-executor";
@@ -18,16 +24,18 @@ import type { WebSearchBackendId } from "./index";
18
24
  import { pacingHeaderDeadline } from "./header-deadline";
19
25
  import { redactSecretString } from "../lib/redact";
20
26
  import { readBoundedResponseBody } from "../lib/bounded-body";
21
- import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
27
+ import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait, isNonReplayableResponse, SendBudgetExhaustedError } from "../lib/upstream-retry";
22
28
  import { rateLimitRetryDelayMs } from "../providers/key-failover";
29
+ import { RequestPacingQueueOverloadError } from "../providers/request-pacing";
23
30
  import {
24
31
  isTranslatorBudgetExceededError,
25
32
  TRANSLATOR_MAX_TURN_BYTES,
26
33
  TranslatorBudgetExceededError,
27
34
  } from "../lib/translator-budget";
28
35
  import { formatWebSearchResults } from "./format-result";
29
- import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError } from "./progress-stream";
36
+ import { parseStreamWithProgress, RoutedModelInactivityError, WebSearchStreamProtocolError, WebSearchAdapterEventError } from "./progress-stream";
30
37
  import { WEB_SEARCH_TOOL_NAME } from "./synthetic-tool";
38
+ import { newSidecarSearchCellId, rememberSidecarSearch, restoreSidecarSearchResults } from "./sidecar-search-replay";
31
39
 
32
40
  const SSE_HEADERS = {
33
41
  "Content-Type": "text/event-stream",
@@ -204,6 +212,9 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
204
212
  };
205
213
 
206
214
  for (const e of events) {
215
+ // Match the bridge: consecutive signature snapshots update one block. The next
216
+ // semantic event (including an empty thinking-block start) closes that block.
217
+ if (signature !== undefined && e.type !== "thinking_signature" && e.type !== "heartbeat") flushVisible();
207
218
  if (e.type === "text_delta" && e.anthropicThinkingSignature) {
208
219
  flushVisible();
209
220
  flushRaw();
@@ -216,7 +227,6 @@ function extractIterationThinking(events: AdapterEvent[]): OcxThinkingContent[]
216
227
  rawReasoning += e.text;
217
228
  } else if (e.type === "thinking_signature") {
218
229
  signature = e.signature;
219
- flushVisible();
220
230
  } else if (e.type === "redacted_thinking") {
221
231
  flushVisible();
222
232
  flushRaw();
@@ -234,8 +244,8 @@ function normalizeQuery(q: string): string {
234
244
  }
235
245
 
236
246
  /**
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
247
+ * Developer nudge for the forced-answer pass. Anthropic keeps its first sent position in the
248
+ * 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
249
  * gathered this turn. Citation wording is conditional — a failed/empty search still wants an answer,
240
250
  * just without fabricated sources.
241
251
  */
@@ -252,10 +262,8 @@ function forcedAnswerNudge(): OcxMessage {
252
262
  }
253
263
 
254
264
  /**
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`.
265
+ * Developer nudge for the ONE recovery pass after an empty forced answer. Tool calls are disabled;
266
+ * Anthropic retains its declared catalog and this nudge in request-local history for prefix fidelity.
259
267
  */
260
268
  function forcedAnswerRetryNudge(): OcxMessage {
261
269
  return {
@@ -269,22 +277,31 @@ function forcedAnswerRetryNudge(): OcxMessage {
269
277
  };
270
278
  }
271
279
 
272
- function jsonError(status: number, message: string): Response {
273
- return new Response(JSON.stringify({ error: { message, type: "upstream_error", code: null } }), {
280
+ function jsonError(status: number, message: string, event?: Extract<AdapterEvent, { type: "error" }>): Response {
281
+ return new Response(JSON.stringify({ error: { message, type: event?.errorType ?? "upstream_error", code: event?.code ?? null, ...(event?.retryable === false ? { retryable: false } : {}) } }), {
274
282
  status,
275
- headers: { "Content-Type": "application/json" },
283
+ headers: { "Content-Type": "application/json", ...(event?.retryable === false ? { "x-should-retry": "false" } : {}) },
276
284
  });
277
285
  }
278
286
 
279
287
  /** Hard provider/parse failure inside an iteration. The eager first iteration converts it to a
280
288
  * non-200 jsonError; later (already-streaming) iterations surface it as an in-stream error event. */
281
289
  class LoopError extends Error {
282
- constructor(readonly status: number, message: string) {
290
+ constructor(
291
+ readonly status: number,
292
+ message: string,
293
+ readonly event?: Extract<AdapterEvent, { type: "error" }>,
294
+ readonly detail?: Pick<SidecarLoopFailure, "upstreamCode" | "nonReplayable">,
295
+ ) {
283
296
  super(message);
284
297
  this.name = "LoopError";
285
298
  }
286
299
  }
287
300
 
301
+ function loopFailure(e: LoopError): SidecarLoopFailure {
302
+ return { status: e.status, message: e.message, ...(e.event ? { event: e.event } : {}), ...e.detail };
303
+ }
304
+
288
305
  /**
289
306
  * Dependencies for one web-search loop iteration: parsed request, active adapter,
290
307
  * incoming metadata, and the configured search executor.
@@ -351,7 +368,8 @@ export interface WebSearchLoopDeps {
351
368
  /** Observe the exact adapter request selected for each routed-model iteration. */
352
369
  onRequestBuilt?: (request: AdapterRequest) => void;
353
370
  /** Request-scoped executor retains the core's selection binding across loop retries. */
354
- fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter) => typeof globalThis.fetch;
371
+ thinkingRecovery?: ReturnType<typeof createAnthropicThinkingRecovery>;
372
+ fetchForRequest?: (request: AdapterRequest, parsed: OcxParsedRequest, adapter: ProviderAdapter, recovery?: AttemptRecoveryKind) => typeof globalThis.fetch;
355
373
  /** Called before each routed-model dispatch in the loop, for attempt telemetry. Same-target 429 replays pass the `rate-limit-429` recovery kind. */
356
374
  onAttemptSend?: (recovery?: AttemptRecoveryKind) => void;
357
375
  /**
@@ -411,9 +429,14 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
411
429
  ? Math.floor(deps.stallTimeoutSec * 1000)
412
430
  : 300_000;
413
431
 
432
+ // Put recorded results back into replayed search pairs before the first iteration (#7269).
433
+ restoreSidecarSearchResults(parsed);
414
434
  const messages: OcxMessage[] = [...parsed.context.messages];
435
+ const replaySnapshots = new Set<AnthropicReplaySnapshot>();
415
436
  const loopT0 = Date.now();
416
- const allTools = parsed.context.tools ?? [];
437
+ const allTools = adapter.name === "anthropic" ? anthropicDeclaredTools(parsed) : parsed.context.tools ?? [];
438
+ let forcedNudgeAdded = false;
439
+ let emptyNudgeAdded = false;
417
440
  let searchesExecuted = 0;
418
441
  let executedSearchCount = 0;
419
442
  // Queries whose search already failed this turn — repeats are short-circuited so a model that keeps
@@ -448,8 +471,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
448
471
  interface IterationResponse {
449
472
  response: Response;
450
473
  responseAdapter: ProviderAdapter;
474
+ recover?: (error: WebSearchAdapterEventError) => Promise<IterationResponse | undefined>;
451
475
  }
452
476
  type IterationSplit = ReturnType<typeof scanEventsForWebSearch> & {
477
+ events: AdapterEvent[];
478
+ anthropicReplay?: AnthropicReplaySnapshot;
453
479
  /**
454
480
  * How many leading passthrough events were already delivered live this iteration. They are
455
481
  * exactly the first N passthrough entries (live delivery stops before the first event that
@@ -464,6 +490,42 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
464
490
  const rateLimitRetryPolicy = deps.retryOn429Policy ?? null;
465
491
  let rateLimitRetries = 0;
466
492
 
493
+ const requireSuccessfulHeaders = async (prepared: IterationResponse): Promise<void> => {
494
+ if (!prepared.response.ok) {
495
+ if (deps.thinkingRecovery && prepared.responseAdapter.name === "anthropic") {
496
+ const event = await deps.thinkingRecovery.projectHttpFailure(prepared.response, signal);
497
+ throw new LoopError(event.status ?? prepared.response.status, event.message, event);
498
+ }
499
+ let body: Awaited<ReturnType<typeof readBoundedResponseBody>>;
500
+ try {
501
+ body = await readBoundedResponseBody(prepared.response, { signal });
502
+ } catch {
503
+ // The response status is authoritative even when its untrusted error body fails while
504
+ // being read (including a synchronous getReader() failure). Never route that failure
505
+ // through the adapter formatter or the generic transport error, which could expose its
506
+ // raw message. A parent/client cancellation still owns the request lifecycle as 499.
507
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
508
+ throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}`, undefined,
509
+ { nonReplayable: isNonReplayableResponse(prepared.response) });
510
+ }
511
+ let formatted = "";
512
+ let upstreamCode: string | undefined;
513
+ if (body.displaySafe && !body.truncated && body.text.trim() && prepared.responseAdapter.formatErrorBody) {
514
+ try {
515
+ formatted = prepared.responseAdapter.formatErrorBody(
516
+ prepared.response.status,
517
+ prepared.response.headers,
518
+ body.text,
519
+ ).trim();
520
+ } catch { /* formatter hooks are best-effort; unsafe raw text is never the fallback */ }
521
+ }
522
+ if (body.displaySafe && !body.truncated) upstreamCode = upstreamErrorCode(body.text);
523
+ const suffix = formatted ? `: ${formatted.slice(0, 400)}` : "";
524
+ throw new LoopError(prepared.response.status, `Provider error ${prepared.response.status}${suffix}`, undefined,
525
+ { upstreamCode, nonReplayable: isNonReplayableResponse(prepared.response) });
526
+ }
527
+ };
528
+
467
529
  // Acquire one iteration's final response headers. The first call is drained eagerly so an initial
468
530
  // connect/header/HTTP failure stays a non-2xx JSON response. Its successful BODY is deliberately
469
531
  // left unread until the downstream Responses SSE bridge exists.
@@ -473,24 +535,30 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
473
535
  * restart) before the `on429` key rotation.
474
536
  */
475
537
  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
538
  const recoveringEmptyAnswer = forceAnswer && emptyAnswerRetries > 0;
489
- if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()];
539
+ const preserveAnthropicPrefix = adapter.name === "anthropic";
540
+ let iterMessages = messages;
541
+ if (preserveAnthropicPrefix) {
542
+ // Once sent, each nudge is part of subsequent signatures' bound prefix. Keep its
543
+ // original position even when a forced-answer response makes another tool call.
544
+ if (forceAnswer && executedSearchCount > 0 && !forcedNudgeAdded) {
545
+ const nudge = forcedAnswerNudge();
546
+ if (nudge.role === "developer" && typeof nudge.content === "string") nudge.content = [{ type: "text", text: nudge.content }];
547
+ messages.push(nudge); forcedNudgeAdded = true;
548
+ }
549
+ if (recoveringEmptyAnswer && !emptyNudgeAdded) {
550
+ const nudge = forcedAnswerRetryNudge();
551
+ if (nudge.role === "developer" && typeof nudge.content === "string") nudge.content = [{ type: "text", text: nudge.content }];
552
+ messages.push(nudge); emptyNudgeAdded = true;
553
+ }
554
+ } else {
555
+ if (forceAnswer && executedSearchCount > 0) iterMessages = [...messages, forcedAnswerNudge()];
556
+ if (recoveringEmptyAnswer) iterMessages = [...iterMessages, forcedAnswerRetryNudge()];
557
+ }
490
558
  const iterParsed: OcxParsedRequest = {
491
559
  ...parsed, stream: true,
492
560
  ...(recoveringEmptyAnswer ? { options: { ...parsed.options, toolChoice: "none" as const } } : {}),
493
- context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer ? [] : allTools },
561
+ context: { ...parsed.context, messages: iterMessages, tools: recoveringEmptyAnswer && !preserveAnthropicPrefix ? [] : allTools },
494
562
  };
495
563
  // One cumulative header deadline spans key rotations and reset sends, excluding local pacing.
496
564
  // clear() stops only its timer after final headers; the direct turn signal remains attached to
@@ -528,7 +596,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
528
596
  cachedRequest = request;
529
597
  cachedAdapter = requestAdapter;
530
598
  }
531
- const requestFetch = headerDeadline.pacedFetch(deps.fetchForRequest?.(request, iterParsed, requestAdapter) ?? routedProviderFetch);
599
+ const requestFetch = headerDeadline.pacedFetch(deps.fetchForRequest?.(request, iterParsed, requestAdapter, recovery) ?? routedProviderFetch);
532
600
  let response: Response;
533
601
  try {
534
602
  if (requestAdapter.fetchResponse) {
@@ -571,9 +639,33 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
571
639
  } finally {
572
640
  request.releaseBodyObservation?.();
573
641
  }
642
+ bindAnthropicRejectionResponse(request, response);
574
643
  return { response, responseAdapter: requestAdapter };
575
644
  };
576
645
 
646
+ const recover = async (error: WebSearchAdapterEventError): Promise<IterationResponse | undefined> => {
647
+ if (signal.aborted || !error.replaySafe || isNonReplayableResponse(prepared.response)
648
+ || !deps.thinkingRecovery?.prepareEvent(error.event.message, prepared.responseAdapter.name, iterParsed, prepared.response)) return;
649
+ cachedRequest = undefined;
650
+ cachedAdapter = undefined;
651
+ // The successful headers ended the previous timer; the replacement owns a fresh deadline.
652
+ headerDeadline = pacingHeaderDeadline(connectTimeoutMs, signal);
653
+ try {
654
+ const replacement = await fetchOnce(prepared.responseAdapter, "opaque-blob-rejection");
655
+ headerDeadline.clear();
656
+ await requireSuccessfulHeaders(replacement);
657
+ return replacement;
658
+ } catch (error) {
659
+ if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
660
+ if (headerDeadline.didExpire()) throw new LoopError(504,
661
+ `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`);
662
+ if (error instanceof LoopError || error instanceof SendBudgetExhaustedError || isTranslatorBudgetExceededError(error)) throw error;
663
+ throw new LoopError(502, `Provider unreachable: ${error instanceof Error ? error.message : String(error)}`);
664
+ } finally {
665
+ headerDeadline.clear();
666
+ deps.onIterationEnd?.();
667
+ }
668
+ };
577
669
  let prepared = await fetchOnce(adapter);
578
670
  // Same-target 429 wait-and-retry (opt-in `retryOn429`) BEFORE key rotation: a primary-key
579
671
  // rate-limit blip replays on the SAME key; rotation only runs after attempts exhaust.
@@ -644,41 +736,25 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
644
736
  prepared = replacement;
645
737
  }
646
738
 
739
+ if (await deps.thinkingRecovery?.prepareHttp(prepared.response, prepared.responseAdapter.name, iterParsed)) {
740
+ try { void prepared.response.body?.cancel().catch(() => {}); } catch { /* closed */ }
741
+ cachedRequest = undefined;
742
+ cachedAdapter = undefined;
743
+ prepared = await fetchOnce(adapter, "opaque-blob-rejection");
744
+ }
745
+
746
+
647
747
  // Final headers have arrived. Clear only the deadline timer before ANY body read.
648
748
  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;
749
+ await requireSuccessfulHeaders(prepared);
750
+ return { ...prepared, recover };
675
751
  } catch (error) {
676
- if (isTranslatorBudgetExceededError(error)) throw error;
752
+ if (isTranslatorBudgetExceededError(error) || error instanceof SendBudgetExhaustedError) throw error;
677
753
  if (headerDeadline.didExpire()) {
678
754
  throw new LoopError(504, `Provider response-header timeout after ${connectTimeoutMs}ms during web-search`);
679
755
  }
680
756
  if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
681
- if (error instanceof LoopError) throw error;
757
+ if (error instanceof LoopError || error instanceof RequestPacingQueueOverloadError) throw error;
682
758
  throw new LoopError(502, `Provider unreachable: ${error instanceof Error ? error.message : String(error)}`);
683
759
  } finally {
684
760
  headerDeadline.clear();
@@ -710,15 +786,25 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
710
786
  const events: AdapterEvent[] = [];
711
787
  let liveWindowOpen = deps.streamRoutedModelOutput === true;
712
788
  let streamedPassthroughCount = 0;
789
+ let anthropicReplay: AnthropicReplaySnapshot | undefined;
790
+ let releaseCapture = () => {};
713
791
  try {
714
- const parse = prepared.responseAdapter.parseStream.bind(prepared.responseAdapter);
792
+ const parse: ProviderAdapter["parseStream"] = (response, budget) => {
793
+ if (prepared.responseAdapter.name === "anthropic") releaseCapture = enableAnthropicReplayCapture(response);
794
+ return prepared.responseAdapter.parseStream(response, budget);
795
+ };
715
796
  for await (const event of parseStreamWithProgress(prepared.response, parse, {
716
797
  signal,
717
798
  inactivityTimeoutMs: routedModelStallTimeoutMs,
718
799
  translatorBudget,
800
+ preserveAdapterError: deps.thinkingRecovery !== undefined,
719
801
  // A pass that fails was still billed; the error path reports it with the hidden passes.
720
802
  onErrorUsage: usage => { hiddenUsage = mergeUsage(hiddenUsage, usage); },
721
803
  })) {
804
+ if (event.type === "done") {
805
+ anthropicReplay = takeAnthropicReplay(event);
806
+ if (anthropicReplay) replaySnapshots.add(anthropicReplay);
807
+ }
722
808
  if (event.type === "heartbeat") yield event;
723
809
  // Kiro's explicit-completion protocol marks ordinary assistant text as commentary while
724
810
  // it performs a bounded final-answer retry. That text is safe to surface immediately and
@@ -740,12 +826,21 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
740
826
  }
741
827
  }
742
828
  } catch (error) {
743
- if (isTranslatorBudgetExceededError(error)) throw error;
829
+ if (isTranslatorBudgetExceededError(error) || error instanceof SendBudgetExhaustedError) throw error;
744
830
  if (signal.aborted) throw new LoopError(499, "client closed request during web-search");
831
+ if (error instanceof WebSearchAdapterEventError) {
832
+ const replacement = await prepared.recover?.(error);
833
+ if (replacement) return yield* consumeIterationEvents(replacement);
834
+ const event = isNonReplayableResponse(prepared.response)
835
+ ? { ...error.event, message: "Provider stream error: upstream diagnostic withheld for a non-replayable failure", retryable: false as const }
836
+ : error.event;
837
+ throw new LoopError(event.status ?? 502, event.message, event);
838
+ }
839
+ if (error instanceof LoopError) throw error;
745
840
  if (error instanceof RoutedModelInactivityError) throw new LoopError(504, error.message);
746
841
  if (error instanceof WebSearchStreamProtocolError) throw new LoopError(502, error.message);
747
842
  throw new LoopError(502, `Provider stream error: ${error instanceof Error ? error.message : String(error)}`);
748
- }
843
+ } finally { releaseCapture(); }
749
844
 
750
845
  const terminalIndexes = events.flatMap((event, index) =>
751
846
  event.type === "done" || event.type === "incomplete" || event.type === "error" ? [index] : []);
@@ -757,9 +852,11 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
757
852
  if (terminal.code === "translation_buffer_limit") {
758
853
  throw new TranslatorBudgetExceededError("retained_collectors", TRANSLATOR_MAX_TURN_BYTES);
759
854
  }
760
- throw new LoopError(502, terminal.message);
855
+ // Keep an adapter's own overflow verdict; every other terminal stays a generic 502.
856
+ throw new LoopError(502, terminal.message, terminal.code === "context_length_exceeded" ? terminal : undefined);
761
857
  }
762
- return { ...scanEventsForWebSearch(events), streamedPassthroughCount };
858
+ deps.thinkingRecovery?.observeIteration(events);
859
+ return { ...scanEventsForWebSearch(events), streamedPassthroughCount, events, anthropicReplay };
763
860
  };
764
861
 
765
862
  // Execute one model-requested web_search call. The call may batch several queries (native
@@ -768,9 +865,10 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
768
865
  // valid, and surface as ONE search cell carrying every attempted query. A real search (one that
769
866
  // hits the sidecar) shows the spinner WHILE the batch runs. Empty/limit/repeat placeholders never
770
867
  // emit a cell (matching the prior single-query behavior).
771
- async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = []): AsyncGenerator<AdapterEvent> {
868
+ async function* runSearchCall(call: WebSearchCall, precedingThinking: OcxThinkingContent[] = [], appendAssistant = true): AsyncGenerator<AdapterEvent> {
772
869
  const results: { query: string; outcome: SidecarOutcome }[] = [];
773
870
  let beganCell = false;
871
+ const cellItemId = newSidecarSearchCellId();
774
872
  if (call.queries.length === 0) {
775
873
  // The model called web_search with neither query nor queries — count it against the budget
776
874
  // (loop-bounding) exactly as the old empty-query placeholder did, but emit no cell.
@@ -792,7 +890,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
792
890
  // Real sidecar search. Open the cell once, before the first real query runs.
793
891
  if (!beganCell) {
794
892
  beganCell = true;
795
- yield { type: "web_search_call_begin", id: call.id };
893
+ yield { type: "web_search_call_begin", id: call.id, itemId: cellItemId };
796
894
  }
797
895
  // F5: the anthropic sidecar authenticates with its own stored OAuth — it never touches the
798
896
  // ChatGPT forward headers and must NOT record a Codex/OpenAI pool outcome.
@@ -845,7 +943,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
845
943
  const callArgs: Record<string, unknown> = call.queries.length > 1
846
944
  ? { queries: call.queries }
847
945
  : { query: call.queries[0] ?? "" };
848
- messages.push({
946
+ if (appendAssistant) messages.push({
849
947
  role: "assistant",
850
948
  content: [
851
949
  // Signed thinking must precede tool_use on replay (Anthropic extended thinking), and
@@ -867,12 +965,15 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
867
965
  });
868
966
  // One aggregated tool result. isError only when EVERY query failed (a partial success is usable).
869
967
  const allFailed = results.every(r => !!r.outcome.error);
968
+ const resultText = formatWebSearchResults(results, !!parsed._structuredOutput);
870
969
  messages.push({
871
970
  role: "toolResult", toolCallId: call.id, toolName: WEB_SEARCH_TOOL_NAME,
872
- content: formatWebSearchResults(results, !!parsed._structuredOutput),
971
+ content: resultText,
873
972
  isError: allFailed, timestamp: now,
874
973
  });
875
974
  if (beganCell) {
975
+ // The client sees only the cell; keep the result so its replay can carry it (#7269).
976
+ rememberSidecarSearch(parsed, cellItemId, { id: call.id, arguments: callArgs }, resultText);
876
977
  // The cell is "completed" if any query produced a usable result, else "failed". `queries`
877
978
  // carries every attempted query so Codex renders the native plural label.
878
979
  const anySuccess = results.some(r => !r.outcome.error);
@@ -888,7 +989,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
888
989
  }
889
990
  }
890
991
  yield {
891
- type: "web_search_call_end", id: call.id,
992
+ type: "web_search_call_end", id: call.id, itemId: cellItemId,
892
993
  queries: call.queries,
893
994
  status: anySuccess ? "completed" : "failed",
894
995
  ...(sources.length > 0 ? { sources } : {}),
@@ -904,7 +1005,9 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
904
1005
  firstPrepared = await prepareIterationDrained(false);
905
1006
  } catch (e) {
906
1007
  if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
907
- if (e instanceof LoopError) return jsonError(e.status, e.message);
1008
+ if (e instanceof LoopError) return markSidecarContextOverflow(jsonError(e.status, e.message, e.event), loopFailure(e));
1009
+ if (e instanceof SendBudgetExhaustedError) return jsonError(429, e.message,
1010
+ { type: "error", message: e.message, errorType: "rate_limit_error", code: e.code, retryable: false });
908
1011
  throw e;
909
1012
  }
910
1013
 
@@ -987,7 +1090,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
987
1090
  hiddenUsage = mergeUsage(hiddenUsage, terminalEvent.usage);
988
1091
  if (!split.hasMalformedToolCall && !split.hasRealToolCall && emptyAnswerRetries === 0) {
989
1092
  emptyAnswerRetries++;
990
- console.warn("[web-search-loop] empty forced answer — retrying once without tools");
1093
+ console.warn("[web-search-loop] empty forced answer — retrying once with tool calls disabled");
991
1094
  yield { type: "heartbeat" };
992
1095
  continue;
993
1096
  }
@@ -1012,10 +1115,36 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
1012
1115
  for (const event of split.passthrough.slice(split.streamedPassthroughCount)) {
1013
1116
  if (event.type === "text_delta" && event.anthropicThinkingSignature) yield event;
1014
1117
  }
1015
- const iterationThinking = extractIterationThinking(split.passthrough);
1118
+ const preserveAnthropicHistory = prepared.responseAdapter.name === "anthropic";
1119
+ if (preserveAnthropicHistory) {
1120
+ if (!split.anthropicReplay) throw new LoopError(502, "Anthropic search history cannot be replayed losslessly");
1121
+ const message = { role: "assistant" as const,
1122
+ content: anthropicSearchAssistantContent(split.events), timestamp: Date.now() };
1123
+ const originalCalls = split.anthropicReplay.content.filter(block => block.type === "tool_use");
1124
+ const priorIds = new Set(messages.flatMap(message => message.role === "assistant"
1125
+ ? message.content.filter(part => part.type === "toolCall").map(part => part.id)
1126
+ : message.role === "toolResult" ? [message.toolCallId] : []));
1127
+ if (originalCalls.some(block => priorIds.has(block.id as string))) {
1128
+ throw new LoopError(502, "Anthropic search replay tool identity changed");
1129
+ }
1130
+ const calls = message.content.filter(part => part.type === "toolCall");
1131
+ if (originalCalls.length !== calls.length || originalCalls.some((block, index) => block.id !== calls[index]?.id)) {
1132
+ throw new LoopError(502, "Anthropic search replay tool identity changed");
1133
+ }
1134
+ // The raw input is authoritative, including non-empty content_block_start input.
1135
+ for (const [index, call] of calls.entries()) call.arguments = originalCalls[index]!.input as Record<string, unknown>;
1136
+ for (const call of split.calls) {
1137
+ const original = originalCalls.find(block => block.id === call.id);
1138
+ if (!original) throw new LoopError(502, "Anthropic search replay tool identity changed");
1139
+ call.queries = parseQueries(JSON.stringify(original.input));
1140
+ }
1141
+ bindAnthropicReplay(message, split.anthropicReplay);
1142
+ messages.push(message);
1143
+ }
1144
+ const iterationThinking = preserveAnthropicHistory ? [] : extractIterationThinking(split.passthrough);
1016
1145
  for (const [callIndex, call] of split.calls.entries()) {
1017
1146
  accountRefusalOutputStarted = true;
1018
- yield* runSearchCall(call, callIndex === 0 ? iterationThinking : []);
1147
+ yield* runSearchCall(call, callIndex === 0 ? iterationThinking : [], !preserveAnthropicHistory);
1019
1148
  }
1020
1149
  } catch (e) {
1021
1150
  // The bridge reports no usage for either error; the iterations already sent still count.
@@ -1029,7 +1158,15 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
1029
1158
  message: "upstream translation buffer exceeded the safe limit",
1030
1159
  };
1031
1160
  } else {
1032
- yield { type: "error", message: e instanceof LoopError ? e.message : (e instanceof Error ? e.message : String(e)) };
1161
+ if (e instanceof SendBudgetExhaustedError) yield { type: "error", status: 429,
1162
+ errorType: "rate_limit_error", code: e.code, message: e.message, retryable: false };
1163
+ // A later iteration's overflow ends the turn as the main path's terminal
1164
+ // context_length_exceeded, so Codex compacts instead of resending (#7316).
1165
+ else yield (e instanceof LoopError ? sidecarLoopTerminalEvent(loopFailure(e)) : undefined)
1166
+ ?? (e instanceof LoopError && e.event ? e.event
1167
+ : e instanceof LoopError && deps.thinkingRecovery
1168
+ ? { type: "error", status: e.status, errorType: "upstream_error", message: e.message }
1169
+ : { type: "error", message: e instanceof Error ? e.message : String(e) });
1033
1170
  }
1034
1171
  return;
1035
1172
  }
@@ -1037,6 +1174,8 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
1037
1174
  if (hiddenUsage) deps.onUsage?.(hiddenUsage);
1038
1175
  yield { type: "error", message: "web-search loop exceeded its iteration cap" };
1039
1176
  } finally {
1177
+ for (const snapshot of replaySnapshots) snapshot.release();
1178
+ replaySnapshots.clear();
1040
1179
  if (abortSignal) abortSignal.removeEventListener("abort", linkAbort);
1041
1180
  }
1042
1181
  }
@@ -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 {
@@ -299,11 +308,15 @@ export async function* parseStreamWithProgress(
299
308
  }
300
309
  if (event.type === "error") {
301
310
  if (event.usage) options.onErrorUsage?.(event.usage);
311
+ // An adapter's own overflow verdict keeps its typed event so the sidecar can end the
312
+ // turn as context_length_exceeded (#7316); other errors keep the plain-message shape.
313
+ const preserve = options.preserveAdapterError || event.code === "context_length_exceeded";
302
314
  fail(event.code === "translation_buffer_limit"
303
315
  ? new TranslatorBudgetExceededError("live_transient", TRANSLATOR_MAX_TURN_BYTES)
304
- : new Error(event.message));
316
+ : preserve ? new WebSearchAdapterEventError(event, replaySafe) : new Error(event.message));
305
317
  return;
306
318
  }
319
+ replaySafe &&= event.type === "heartbeat" && event.replayUnsafe !== true;
307
320
  if (event.type === "done" || event.type === "incomplete") {
308
321
  heldTerminal = event;
309
322
  // Response-byte inactivity ends once the adapter has produced a terminal event.
@@ -43,6 +43,7 @@ import {
43
43
  type TranslatorBudget,
44
44
  } from "../lib/translator-budget";
45
45
  import { jsonUtf8Bytes } from "../lib/json-byte-size";
46
+ import { newSidecarSearchCellId, rememberSidecarSearch, restoreSidecarSearchResults } from "./sidecar-search-replay";
46
47
 
47
48
  export interface RunTurnWebSearchDeps {
48
49
  parsed: OcxParsedRequest;
@@ -77,6 +78,8 @@ export interface RunTurnWebSearchDeps {
77
78
  * active: same request plus the synthetic tool so the model can ask to search.
78
79
  */
79
80
  export function runTurnWebSearchInitialParsed(parsed: OcxParsedRequest): OcxParsedRequest {
81
+ // Runs before the first dispatch: put recorded results back into replayed search pairs (#7269).
82
+ restoreSidecarSearchResults(parsed);
80
83
  return {
81
84
  ...parsed,
82
85
  context: {
@@ -296,6 +299,7 @@ export async function* runTurnWebSearchLoop(
296
299
  ): AsyncGenerator<AdapterEvent> {
297
300
  const results: { query: string; outcome: SidecarOutcome }[] = [];
298
301
  let beganCell = false;
302
+ const cellItemId = newSidecarSearchCellId();
299
303
  if (call.queries.length === 0) {
300
304
  searchesExecuted++;
301
305
  results.push({ query: "", outcome: { text: "", sources: [], error: "the model called web_search with an empty query" } });
@@ -318,7 +322,7 @@ export async function* runTurnWebSearchLoop(
318
322
  } else {
319
323
  if (!beganCell) {
320
324
  beganCell = true;
321
- yield { type: "web_search_call_begin", id: call.id };
325
+ yield { type: "web_search_call_begin", id: call.id, itemId: cellItemId };
322
326
  }
323
327
  if (abortSignal?.aborted) {
324
328
  yield { type: "web_search_call_end", id: call.id, queries: call.queries, status: "failed" };
@@ -358,12 +362,14 @@ export async function* runTurnWebSearchLoop(
358
362
  timestamp: now,
359
363
  });
360
364
  const allFailed = results.every(r => !!r.outcome.error);
365
+ const resultText = formatWebSearchResults(results, !!parsed._structuredOutput);
361
366
  appendMessage({
362
367
  role: "toolResult", toolCallId: call.id, toolName: WEB_SEARCH_TOOL_NAME,
363
- content: formatWebSearchResults(results, !!parsed._structuredOutput),
368
+ content: resultText,
364
369
  isError: allFailed, timestamp: now,
365
370
  });
366
371
  if (beganCell) {
372
+ rememberSidecarSearch(parsed, cellItemId, { id: call.id, arguments: callArgs }, resultText);
367
373
  const anySuccess = results.some(r => !r.outcome.error);
368
374
  const sources: { url: string; title?: string }[] = [];
369
375
  const seenSrc = new Set<string>();
@@ -375,7 +381,7 @@ export async function* runTurnWebSearchLoop(
375
381
  }
376
382
  }
377
383
  yield {
378
- type: "web_search_call_end", id: call.id,
384
+ type: "web_search_call_end", id: call.id, itemId: cellItemId,
379
385
  queries: call.queries,
380
386
  status: anySuccess ? "completed" : "failed",
381
387
  ...(sources.length > 0 ? { sources } : {}),