@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,6 @@
1
+ import { captureAnthropicRejectionPayloads, keepAnthropicReplayBlock } from "./anthropic-rejection-memo";
2
+ import { anthropicDeclaredTools } from "./anthropic-tool-catalog";
3
+ import { anthropicReplayContent, createAnthropicReplayCapture } from "./anthropic-replay";
1
4
  import { withAnthropicProgressUpdates } from "./anthropic-progress";
2
5
  import type { IncomingMeta, ProviderAdapter } from "./base";
3
6
  import { createAdapterTierMetadata, type AdapterTierMetadata } from "../providers/fastwire";
@@ -17,7 +20,7 @@ import type {
17
20
  OcxToolResultMessage,
18
21
  OcxUsage,
19
22
  } from "../types";
20
- import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types";
23
+ import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName } from "../types";
21
24
  import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPrefix, stripClaudeToolPrefix } from "../oauth/anthropic";
22
25
  import { parseDataUrl } from "./image";
23
26
  import { enforceAnthropicImageLimits } from "./anthropic-image-guard";
@@ -283,7 +286,7 @@ function normalizeTtlOrdering(body: Record<string, unknown>): void {
283
286
  }
284
287
  }
285
288
 
286
- function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string {
289
+ export function isLikelyRealAnthropicThinkingSignature(signature: string | undefined): signature is string {
287
290
  if (typeof signature !== "string" || signature.length < 16) return false;
288
291
  if (/^(fc|call|msg|rs|resp|reasoning|item|ws|tool|func|function)[-_]/i.test(signature)) return false;
289
292
  return /^[A-Za-z0-9+/_=-]+$/.test(signature);
@@ -522,13 +525,13 @@ function streamedToolArgumentsParse(assembled: string): boolean {
522
525
  }
523
526
  }
524
527
 
525
- /** A string field of a server tool's streamed input (web_search `query`, web_fetch `url`); "" when it did not parse. */
526
- function serverToolInputField(json: string, key: "query" | "url"): string {
528
+ /** A server tool's complete streamed input; undefined when it cannot be recovered. */
529
+ function serverToolInput(json: string): Record<string, unknown> | undefined {
527
530
  try {
528
- const value = (JSON.parse(json) as Record<string, unknown> | null)?.[key];
529
- return typeof value === "string" ? value : "";
531
+ const input = JSON.parse(json) as unknown;
532
+ return isAnthropicRecord(input) ? input : undefined;
530
533
  } catch {
531
- return "";
534
+ return undefined;
532
535
  }
533
536
  }
534
537
 
@@ -735,7 +738,16 @@ function orphanToolResultContent(msg: OcxToolResultMessage): string | unknown[]
735
738
  function messagesToAnthropicFormat(
736
739
  parsed: OcxParsedRequest,
737
740
  toolNames: { toWire: (name: string) => string },
741
+ serverReplay?: ReadonlyMap<string, readonly Record<string, unknown>[]>,
738
742
  ): { system: string | undefined; messages: unknown[] } {
743
+ // The client's server-tool exchanges go back ahead of the block they preceded. Their anchors are
744
+ // unique in the caller's history (replayedAnthropicServerBlocks drops repeated ones); each is used once.
745
+ const pendingReplay = new Map(serverReplay);
746
+ const replayed = (anchor: string) => {
747
+ const blocks = pendingReplay.get(anchor) ?? [];
748
+ pendingReplay.delete(anchor);
749
+ return blocks;
750
+ };
739
751
  // One allocator for the whole request: a tool_result must resolve to the SAME wire id its
740
752
  // call got, and two distinct raw ids must never collapse into one. Conforming ids are claimed
741
753
  // first so a rewritten id can never squat on an id another call legitimately owns.
@@ -750,8 +762,8 @@ function messagesToAnthropicFormat(
750
762
  }
751
763
  }
752
764
  const toolCatalogNudge = buildNonOpenAIToolCatalogNudgeForTools(
753
- parsed.context.tools,
754
- parsed.options.toolChoice,
765
+ anthropicDeclaredTools(parsed),
766
+ undefined,
755
767
  tool => toolNames.toWire(namespacedToolName(tool.namespace, tool.name)),
756
768
  );
757
769
  const systemParts = [...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])];
@@ -781,6 +793,7 @@ function messagesToAnthropicFormat(
781
793
  case "assistant": {
782
794
  const aMsg = msg as OcxAssistantMessage;
783
795
  const preface: unknown[] = [];
796
+
784
797
  const toolUses: unknown[] = [];
785
798
  const originalOrder: unknown[] = [];
786
799
  const appendPreface = (block: unknown) => { preface.push(block); originalOrder.push(block); };
@@ -789,9 +802,13 @@ function messagesToAnthropicFormat(
789
802
  for (const part of aMsg.content) {
790
803
  if (part.type === "text") {
791
804
  const text = (part as OcxTextContent).text;
805
+ for (const b of replayed(`text:${text}`)) appendPreface(b);
792
806
  if (text) appendPreface({ type: "text", text });
793
807
  } else if (part.type === "thinking") {
794
808
  const t = part as OcxThinkingContent;
809
+ // Server blocks that preceded this reasoning keep their place, even when it is dropped below.
810
+ for (const data of t.redacted ?? []) for (const b of replayed(`redacted:${data}`)) appendPreface(b);
811
+ for (const b of replayed(`thinking:${t.signature}`)) appendPreface(b);
795
812
  // History minted under another serving identity (or already rejected as opaque) is not
796
813
  // this destination's to verify: drop its opaque blocks, as the Responses passthrough does.
797
814
  if (parsed._stripReasoningEncryptedContent === true) continue;
@@ -806,6 +823,7 @@ function messagesToAnthropicFormat(
806
823
  }
807
824
  } else if (part.type === "toolCall") {
808
825
  const tc = part as OcxToolCall;
826
+ for (const b of replayed(`call:${tc.id}`)) appendPreface(b);
809
827
  const flatName = namespacedToolName(tc.namespace, tc.name);
810
828
  // Normalized here, and identically for the matching tool_result above, so a history
811
829
  // replayed from another provider path keeps its call/result pairing (#1767).
@@ -824,9 +842,15 @@ function messagesToAnthropicFormat(
824
842
  }
825
843
  // Anthropic treats text/thinking after tool_use as ending the tool turn, which makes
826
844
  // earlier tool_use ids look unpaired (#620 / common multi-step history shape).
827
- // Signed native blocks bind the source ordering, including updates between tool calls.
828
- // Keep the legacy repair only for unsigned/cross-provider history.
829
- const content = signed ? originalOrder : [...preface, ...toolUses];
845
+ const captured = anthropicReplayContent(aMsg);
846
+ if (captured) {
847
+ const expected = toolUses as Array<{ id: string; name: string }>;
848
+ const actual = captured.filter(block => block.type === "tool_use");
849
+ if (actual.length !== expected.length || actual.some((block, index) => block.id !== expected[index]?.id || block.name !== expected[index]?.name)) {
850
+ throw new Error("Anthropic search replay tool identity changed");
851
+ }
852
+ }
853
+ const content = (captured ?? (signed ? originalOrder : [...preface, ...toolUses])).filter(block => keepAnthropicReplayBlock(parsed, block as Record<string, unknown>));
830
854
  if (content.length === 0) break;
831
855
  messages.push({ role: "assistant", content });
832
856
  if (toolUseIds.length > 0) {
@@ -889,9 +913,7 @@ function messagesToAnthropicFormat(
889
913
 
890
914
  function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }): unknown[] | undefined {
891
915
  if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
892
- const tools = isAllowedToolChoice(parsed.options.toolChoice)
893
- ? parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools))
894
- : parsed.context.tools;
916
+ const tools = anthropicDeclaredTools(parsed);
895
917
  if (tools.length === 0) return undefined;
896
918
  const converted = tools.map(t => ({
897
919
  name: toolNames.toWire(namespacedToolName(t.namespace, t.name)),
@@ -998,7 +1020,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
998
1020
  throw new Error("anthropic provider requires a non-empty apiKey (authMode: key)");
999
1021
  }
1000
1022
 
1001
- const { system, messages } = messagesToAnthropicFormat(parsed, toolNames);
1023
+ // Only api.anthropic.com runs Anthropic's server tools, so only it gets their history back.
1024
+ const { system, messages } = messagesToAnthropicFormat(parsed, toolNames,
1025
+ usesNativeAnthropicEndpoint(provider) ? parsed._anthropicServerReplay : undefined);
1002
1026
  // Before image normalization, so the framing block is present for every downstream pass.
1003
1027
  if (isAgentRouterEndpoint(provider.baseUrl)) applyAgentRouterLanguageFraming(messages);
1004
1028
  // Primary image layer: resize/re-encode to fit Anthropic limits without dropping
@@ -1210,7 +1234,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1210
1234
  enforceCacheControlLimit(body, explicitLimit);
1211
1235
  normalizeTtlOrdering(body);
1212
1236
 
1213
- return {
1237
+ const request = {
1214
1238
  url,
1215
1239
  method: "POST",
1216
1240
  headers,
@@ -1224,6 +1248,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1224
1248
  fastSpeed?.value ?? null,
1225
1249
  ),
1226
1250
  };
1251
+ captureAnthropicRejectionPayloads(request, body, incoming?.translatorBudget);
1252
+ return request;
1227
1253
  },
1228
1254
 
1229
1255
  async *parseStream(
@@ -1236,6 +1262,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1236
1262
  return;
1237
1263
  }
1238
1264
 
1265
+ const replayCapture = createAnthropicReplayCapture(response, budget);
1239
1266
  const budgetEncoder = new TextEncoder();
1240
1267
  let currentBlockType = "";
1241
1268
  let currentToolCallId = "";
@@ -1270,11 +1297,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1270
1297
  yield anthropicContentFilterIncomplete(pendingStopReason, usageFromAnthropic(pendingUsage));
1271
1298
  return;
1272
1299
  }
1273
- yield {
1300
+ const done: AdapterEvent = {
1274
1301
  type: "done",
1275
1302
  usage: usageFromAnthropic(pendingUsage),
1276
1303
  ...(pendingStopReason ? { stopReason: pendingStopReason } : {}),
1277
1304
  };
1305
+ replayCapture?.finish(done);
1306
+ yield done;
1278
1307
  };
1279
1308
 
1280
1309
  try {
@@ -1294,6 +1323,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1294
1323
  try {
1295
1324
  parsed = JSON.parse(payload);
1296
1325
  } catch {
1326
+ replayCapture?.invalidate();
1297
1327
  debugDroppedFrame("anthropic", payload);
1298
1328
  continue;
1299
1329
  }
@@ -1301,10 +1331,12 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1301
1331
  // it and the `data.type` read below crashed the stream. Drop a non-record frame the same
1302
1332
  // way an unparseable one is dropped, so the message_stop check still governs the outcome.
1303
1333
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1334
+ replayCapture?.invalidate();
1304
1335
  debugDroppedFrame("anthropic", payload);
1305
1336
  continue;
1306
1337
  }
1307
1338
  const data = parsed as Record<string, unknown>;
1339
+ replayCapture?.accept({ ...data, type: record.event || data.type });
1308
1340
 
1309
1341
  switch (record.event || data.type) {
1310
1342
  case "message_start": {
@@ -1317,6 +1349,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1317
1349
  case "content_block_start": {
1318
1350
  const block = data.content_block as { type: string; id?: string; name?: unknown; data?: string; thinking?: string; tool_use_id?: unknown; content?: unknown } | undefined;
1319
1351
  if (!block) break;
1352
+ // Even an unmodeled server tool or empty block is semantic progress.
1353
+ // Fence replays before a later refusal; unknown blocks must not look like pings.
1354
+ yield { type: "heartbeat", replayUnsafe: true };
1320
1355
  currentBlockType = block.type;
1321
1356
  if (block.type === "thinking") {
1322
1357
  // Preserve even a display:omitted block boundary. The bridge can then
@@ -1336,36 +1371,26 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1336
1371
  // no result in this response and must not surface as a completed search.
1337
1372
  serverSearch = { id: block.id, json: "", streaming: true };
1338
1373
  }
1339
- if (block.type === "web_search_tool_result" && typeof block.tool_use_id === "string") {
1374
+ if ((block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") && typeof block.tool_use_id === "string") {
1375
+ // The begin/end pair the sidecar emits, plus both blocks verbatim so the Claude client holds a
1376
+ // replayable copy. Without this response's matching call and complete input, drop the
1377
+ // exchange instead of inventing a call that could poison the client's next request.
1378
+ const fetch = block.type === "web_fetch_tool_result";
1379
+ const input = serverSearch?.id === block.tool_use_id ? serverToolInput(serverSearch.json) : undefined;
1380
+ if (!input) break;
1340
1381
  const hits = Array.isArray(block.content) ? block.content as Array<{ url?: unknown; title?: unknown }> : undefined;
1341
- // The begin/end pair the sidecar emits. A result whose call came in an earlier response has no query here.
1342
- const query = serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "query") : "";
1343
1382
  yield { type: "web_search_call_begin", id: block.tool_use_id };
1344
1383
  yield {
1345
1384
  type: "web_search_call_end",
1346
1385
  id: block.tool_use_id,
1347
- queries: query ? [query] : [],
1348
- // A failed search carries one error object instead of the hit list.
1349
- status: hits ? "completed" : "failed",
1386
+ queries: typeof input.query === "string" && input.query.length > 0 ? [input.query] : [],
1387
+ // A failed call carries one error object instead of the hit list / fetched page.
1388
+ status: (fetch ? (block.content as { type?: unknown } | undefined)?.type === "web_fetch_result" : hits) ? "completed" : "failed",
1350
1389
  sources: (hits ?? []).flatMap(h => typeof h.url === "string"
1351
1390
  ? [{ url: h.url, ...(typeof h.title === "string" ? { title: h.title } : {}) }] : []),
1352
- };
1353
- serverSearch = undefined;
1354
- }
1355
- if (block.type === "web_fetch_tool_result" && typeof block.tool_use_id === "string") {
1356
- // Relayed as the same cell, opened at the result for the same reason; the result is
1357
- // carried verbatim (a web_fetch_result document or a web_fetch_tool_error).
1358
- const result = block.content ?? { type: "web_fetch_tool_error", error_code: "unavailable" };
1359
- const url = (result as { url?: unknown }).url;
1360
- yield { type: "web_search_call_begin", id: block.tool_use_id };
1361
- yield {
1362
- type: "web_search_call_end",
1363
- id: block.tool_use_id,
1364
- queries: [],
1365
- status: (result as { type?: unknown }).type === "web_fetch_result" ? "completed" : "failed",
1366
- fetch: {
1367
- url: typeof url === "string" ? url : serverSearch?.id === block.tool_use_id ? serverToolInputField(serverSearch.json, "url") : "",
1368
- result,
1391
+ anthropic: {
1392
+ use: { type: "server_tool_use", id: block.tool_use_id, name: fetch ? "web_fetch" : "web_search", input },
1393
+ result: block as Record<string, unknown>,
1369
1394
  },
1370
1395
  };
1371
1396
  serverSearch = undefined;
@@ -1415,8 +1440,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1415
1440
  yield { type: "tool_call_delta", arguments: delta.partial_json };
1416
1441
  } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && serverSearch?.streaming
1417
1442
  && serverSearch.json.length < 4096) {
1418
- // Capped: an input past 4 KiB loses its label (query / url), never the call.
1419
- serverSearch.json += delta.partial_json;
1443
+ // Capped: input that cannot be recovered drops its exchange instead of inventing a call.
1444
+ serverSearch.json += delta.partial_json.slice(0, 4096 - serverSearch.json.length);
1420
1445
  }
1421
1446
  break;
1422
1447
  }
@@ -1463,8 +1488,13 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1463
1488
  break;
1464
1489
  }
1465
1490
  case "error": {
1466
- const err = data.error as { message?: string } | undefined;
1467
- yield { type: "error", message: err?.message ?? "Anthropic error" };
1491
+ const err = data.error as { message?: unknown; type?: unknown; code?: unknown; status?: unknown } | undefined;
1492
+ yield { type: "error", message: typeof err?.message === "string" ? err.message : "Anthropic error",
1493
+ ...(typeof err?.type === "string" ? { errorType: err.type } : {}),
1494
+ ...(typeof err?.code === "string" ? { code: err.code } : {}),
1495
+ ...(typeof err?.status === "number" && Number.isInteger(err.status) && err.status >= 400 && err.status <= 599
1496
+ ? { status: err.status } : {}),
1497
+ };
1468
1498
  return;
1469
1499
  }
1470
1500
  }
@@ -1483,6 +1513,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
1483
1513
  // one-terminal-event contract for consumers that keep draining the generator.
1484
1514
  return;
1485
1515
  } finally {
1516
+ replayCapture?.discard();
1486
1517
  if (currentToolCallId) budget.closeCall(currentToolCallId);
1487
1518
  }
1488
1519
  if (!emittedDone) {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Gemini progress-text history trimming.
3
+ *
4
+ * Gemini models, observed on Gemini 4 Argon over Cloud Code Assist, sometimes skip their thinking
5
+ * channel on a tool-calling turn and plan in the visible text instead. The provider sends that
6
+ * planning as an ordinary text part without a thought marker, for example a progress line in the
7
+ * user's language followed by "Let's read google.ts 580-1700 first." Once one turn like that is
8
+ * replayed as history, the model copies the pattern on the turns that follow, so the leak grows
9
+ * through the rest of the session. Replaying the same 300K-token request with the leaked tails
10
+ * removed from the history stopped the leak, while stronger system-prompt wording did not.
11
+ *
12
+ * The fix is on the history side only. Text that precedes the tool call of a replayed model turn is
13
+ * intermediate progress, and the Google brevity instruction already asks for a single short line
14
+ * there, so it is replayed as its lead sentence. A turn Codex marks `final_answer` and text after a
15
+ * tool call are replayed whole. Visible output is never rewritten, final
16
+ * answers (turns without tool calls) are replayed in full, and the split is punctuation-based so
17
+ * it works the same in every language without matching any wording.
18
+ */
19
+
20
+ /**
21
+ * Sentence end: ASCII terminal punctuation followed by whitespace or the end (so "google.ts" and
22
+ * "3.5" are not boundaries), a script's own sentence terminator (CJK 。!?, Devanagari
23
+ * ।॥, Arabic ۔؟, Armenian ։, Ethiopic ።, Myanmar ။), or a line break. Closing quotes, brackets and
24
+ * Markdown emphasis right after the punctuation belong to the sentence.
25
+ */
26
+ const SENTENCE_END = /[.!?]["'\u201D\u2019)\]*_`]*(?=\s|$)|[\u3002\uFF01\uFF1F\u0964\u0965\u06D4\u061F\u0589\u1362\u104B]["'\u201D\u2019\u300D\u300F)\]*_`]*|\n/g;
27
+
28
+ /** A boundary that only closes a leading list marker ("1.", "a.") or a common abbreviation ("e.g."). */
29
+ const NOT_A_SENTENCE = /^(?:\d{1,3}|[a-z])\.$|(?:^|\s)(?:e\.g|i\.e|etc|vs)\.$/i;
30
+
31
+ /** Whether replayed progress text for this model is trimmed to its lead sentence. */
32
+ export function trimsGeminiProgressHistory(modelId: string): boolean {
33
+ return /gemini/i.test(modelId);
34
+ }
35
+
36
+ /**
37
+ * The lead sentence of a replayed progress text, or undefined when nothing usable remains.
38
+ * Text without a sentence boundary is already a single sentence and is returned whole.
39
+ */
40
+ export function geminiProgressLead(text: string): string | undefined {
41
+ const trimmed = text.trim();
42
+ if (trimmed.length === 0) return undefined;
43
+ SENTENCE_END.lastIndex = 0;
44
+ let end = trimmed.length;
45
+ for (let match = SENTENCE_END.exec(trimmed); match; match = SENTENCE_END.exec(trimmed)) {
46
+ const boundary = match.index + match[0].length;
47
+ if (!NOT_A_SENTENCE.test(trimmed.slice(0, boundary).trim())) { end = boundary; break; }
48
+ }
49
+ const lead = trimmed.slice(0, end).trim();
50
+ // A code-fence opener is not a sentence; replaying it alone would leave an unclosed fence.
51
+ if (lead.length === 0 || lead.startsWith("```")) return undefined;
52
+ return lead;
53
+ }
@@ -44,6 +44,7 @@ import {
44
44
  type TranslatorBudget,
45
45
  } from "../lib/translator-budget";
46
46
  import { buildNonOpenAIToolCatalogNudgeForTools } from "./tool-catalog-nudge";
47
+ import { geminiProgressLead, trimsGeminiProgressHistory } from "./google-progress-history";
47
48
  import { configuredReasoningEfforts, mapReasoningEffort } from "../reasoning-effort";
48
49
 
49
50
  // Google-family models (Gemini/Vertex/Antigravity) tend to emit long running commentary between
@@ -466,11 +467,26 @@ function messagesToGeminiFormat(
466
467
  const aMsg = msg as OcxAssistantMessage;
467
468
  const parts: unknown[] = [];
468
469
  const toolCalls: Array<{ wireCallId: string; wireName: string }> = [];
470
+ // Progress text before a Gemini tool call replays as its lead sentence, so planning the
471
+ // model leaked into visible text is not copied forward (google-progress-history.ts).
472
+ const trimProgress = trimsGeminiProgressHistory(identityModelId)
473
+ && aMsg.phase !== "final_answer"
474
+ && aMsg.content.some(part => part.type === "toolCall");
475
+ let progressLeadKept = false;
476
+ let afterToolCall = false;
469
477
  for (const p of aMsg.content) {
470
478
  if (p.type === "text") {
471
- const textPart = geminiTextPart((p as OcxTextContent).text);
479
+ let text: unknown = (p as OcxTextContent).text;
480
+ if (trimProgress && !afterToolCall) {
481
+ if (progressLeadKept || typeof text !== "string") continue;
482
+ text = geminiProgressLead(text);
483
+ if (text === undefined) continue;
484
+ progressLeadKept = true;
485
+ }
486
+ const textPart = geminiTextPart(text);
472
487
  if (textPart) parts.push(textPart);
473
488
  } else if (p.type === "toolCall") {
489
+ afterToolCall = true;
474
490
  const tc = p as OcxToolCall;
475
491
  // Preserve the thought signature on the function-call part so Antigravity/Gemini-3
476
492
  // reasoning continuity survives history-driven (stateless) turns, not just same-process
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AdapterEvent,
3
+ AnthropicServerBlocks,
3
4
  OcxMessagePhase,
4
5
  OcxProviderContinuationState,
5
6
  OcxProviderOpaqueToolCallMetadata,
@@ -186,9 +187,12 @@ export function adapterFailureFromEvent(event: Extract<AdapterEvent, { type: "er
186
187
  * This fixes items created from here on. History recorded before it is repaired at the
187
188
  * replay boundary by `backfillWebSearchQueries()` in the Responses adapter.
188
189
  */
189
- export function webSearchAction(queries: string[], fetch?: { url: string }): Record<string, unknown> {
190
+ export function webSearchAction(queries: string[], anthropic?: AnthropicServerBlocks): Record<string, unknown> {
190
191
  // Anthropic's server-side web_fetch opens one page instead of searching.
191
- if (fetch) return { type: "open_page", url: fetch.url };
192
+ if (anthropic?.use.name === "web_fetch") {
193
+ const url = (anthropic.result.content as { url?: unknown } | undefined)?.url ?? (anthropic.use.input as { url?: unknown }).url;
194
+ return { type: "open_page", url: typeof url === "string" ? url : "" };
195
+ }
192
196
  const first = queries[0] ?? "";
193
197
  return { type: "search", query: first, queries: queries.length > 0 ? queries : [first] };
194
198
  }
@@ -547,9 +547,9 @@ function buildResponseJSONWithBudget(
547
547
  const safeSources = safeWebSearchSources(e.sources);
548
548
  pushOutput({
549
549
  type: "web_search_call", id: `ws_${uuid()}`, status: e.status ?? "completed",
550
- action: webSearchAction(e.queries, e.fetch),
550
+ action: webSearchAction(e.queries, e.anthropic),
551
551
  ...(safeSources.length > 0 ? { sources: safeSources } : {}),
552
- ...(e.fetch ? { fetch_result: e.fetch.result } : {}),
552
+ ...(e.anthropic ? { anthropic_blocks: e.anthropic } : {}),
553
553
  });
554
554
  if (safeSources.length > 0) {
555
555
  for (const source of safeSources) {
package/src/bridge/sse.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { anthropicProgressItems } from "../responses/anthropic-progress";
2
2
  import type {
3
3
  AdapterEvent,
4
+ AnthropicServerBlocks,
4
5
  OcxMessagePhase,
5
6
  OcxProviderContinuationState,
6
7
  OcxProviderOpaqueToolCallMetadata,
@@ -685,15 +686,15 @@ export function bridgeToResponsesSSE(
685
686
  // leaves a "Searching the web" spinner spinning forever.
686
687
  // `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so
687
688
  // downstream translators (claude outbound) can fill web_search_tool_result content.
688
- // `fetch` (Anthropic's server-side web_fetch) makes the cell a page open and rides its result
689
- // verbatim as `fetch_result`, for the same claude outbound translator.
690
- const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], fetch?: { url: string; result: unknown }) => {
689
+ // `anthropic` (Anthropic's server-side call) rides verbatim as `anthropic_blocks`, for the same
690
+ // claude outbound translator; a web_fetch makes the cell a page open.
691
+ const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[], anthropic?: AnthropicServerBlocks) => {
691
692
  if (!currentWebSearch) return;
692
693
  const item = {
693
694
  type: "web_search_call", id: currentWebSearch.itemId, status,
694
- action: webSearchAction(queries, fetch),
695
+ action: webSearchAction(queries, anthropic),
695
696
  ...(sources && sources.length > 0 ? { sources } : {}),
696
- ...(fetch ? { fetch_result: fetch.result } : {}),
697
+ ...(anthropic ? { anthropic_blocks: anthropic } : {}),
697
698
  };
698
699
  emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item });
699
700
  retainFinishedItem(item as OutputItem);
@@ -1231,7 +1232,7 @@ export function bridgeToResponsesSSE(
1231
1232
  currentWebSearch = { itemId: wsItemId2, eventId: event.id, outputIndex };
1232
1233
  }
1233
1234
  const safeSources = safeWebSearchSources(event.sources);
1234
- closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.fetch);
1235
+ closeCurrentWebSearch(event.status ?? "completed", event.queries, safeSources, event.anthropic);
1235
1236
  // Queue this search's sources for the next assistant message (dedup by URL).
1236
1237
  if (safeSources.length > 0) {
1237
1238
  for (const source of safeSources) {
@@ -87,6 +87,72 @@ export function declaredAnthropicServerTools(raw: unknown): Rec[] | undefined {
87
87
  return tools.length > 0 ? tools : undefined;
88
88
  }
89
89
 
90
+ /** Validate original call/result order without inventing inputs or pairing a later call backwards. */
91
+ function replayableServerRun(run: readonly Rec[]): boolean {
92
+ const pending = new Map<string, Rec>();
93
+ const completed = new Set<string>();
94
+ for (const block of run) {
95
+ if (block.type === "server_tool_use") {
96
+ const { id, name, input } = block;
97
+ if (typeof id !== "string" || !id.startsWith("srvtoolu_") || pending.has(id) || completed.has(id)
98
+ || (name !== "web_search" && name !== "web_fetch") || !isRec(input)) return false;
99
+ const prototype = Object.getPrototypeOf(input);
100
+ const required = input[name === "web_search" ? "query" : "url"];
101
+ if ((prototype !== Object.prototype && prototype !== null)
102
+ || typeof required !== "string" || required.trim().length === 0) return false;
103
+ pending.set(id, block);
104
+ } else {
105
+ const id = block.tool_use_id;
106
+ if (typeof id !== "string") return false;
107
+ const call = pending.get(id);
108
+ if (!call || block.type !== `${call.name}_tool_result`
109
+ || (Array.isArray(block.content) && !block.content.every(h => isRec(h) && typeof h.encrypted_content === "string"))) return false;
110
+ pending.delete(id);
111
+ completed.add(id);
112
+ }
113
+ }
114
+ return completed.size > 0 && pending.size === 0;
115
+ }
116
+
117
+ /**
118
+ * Completed Anthropic server-tool exchanges (`server_tool_use` + `web_search_tool_result` /
119
+ * `web_fetch_tool_result`) in the caller's assistant history. The Responses body drops them, so
120
+ * an Anthropic destination re-inserts them out of body, keyed by the block that follows each run
121
+ * in its message: `call:<tool_use id>`, `text:<text>`, `thinking:<signature>` or
122
+ * `redacted:<data>`, all of which survive translation. An anchor that occurs more than once in the
123
+ * assistant history (string content included) cannot name one owner, so its run stays dropped,
124
+ * as it was before replay existed. A run with none, an unpaired call, a
125
+ * non-Anthropic id, or a search hit without `encrypted_content` is not replayable and stays dropped.
126
+ */
127
+ export function replayedAnthropicServerBlocks(raw: unknown): Map<string, Rec[]> | undefined {
128
+ if (!isRec(raw) || !Array.isArray(raw.messages)) return undefined;
129
+ const out = new Map<string, Rec[]>();
130
+ const seen = new Map<string, number>();
131
+ const count = (anchor: string) => seen.set(anchor, (seen.get(anchor) ?? 0) + 1);
132
+ for (const message of raw.messages) {
133
+ if (!isRec(message) || message.role !== "assistant") continue;
134
+ if (typeof message.content === "string") count(`text:${message.content}`);
135
+ if (!Array.isArray(message.content)) continue;
136
+ let run: Rec[] = [];
137
+ for (const block of message.content.filter(isRec)) {
138
+ if (block.type === "server_tool_use" || block.type === "web_search_tool_result" || block.type === "web_fetch_tool_result") {
139
+ // The caller's breakpoints are dropped on this lane, as for the declared tools above.
140
+ run.push({ ...block, cache_control: undefined });
141
+ continue;
142
+ }
143
+ const anchor = block.type === "tool_use" ? `call:${String(block.id)}`
144
+ : block.type === "text" ? `text:${String(block.text)}`
145
+ : block.type === "thinking" ? `thinking:${String(block.signature)}`
146
+ : block.type === "redacted_thinking" ? `redacted:${String(block.data)}` : undefined;
147
+ if (anchor) count(anchor);
148
+ if (anchor && replayableServerRun(run)) out.set(anchor, run);
149
+ run = [];
150
+ }
151
+ }
152
+ for (const anchor of out.keys()) if ((seen.get(anchor) ?? 0) > 1) out.delete(anchor);
153
+ return out.size > 0 ? out : undefined;
154
+ }
155
+
90
156
  export function toolChoiceToResponses(choice: unknown, body: Rec): void {
91
157
  if (!isRec(choice)) return;
92
158
  if (choice.disable_parallel_tool_use === true) body.parallel_tool_calls = false;
@@ -223,16 +223,24 @@ export function sanitizeWebSearchInput(input: unknown): Rec {
223
223
  /**
224
224
  * Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
225
225
  * input (query/queries) and the web_search_tool_result content (hits, or the error
226
- * object when the search failed). An item carrying `fetch_result` is Anthropic's
227
- * server-side web_fetch and maps to web_fetch + its web_fetch_tool_result, verbatim.
228
- * Shared by the SSE and JSON translation paths.
226
+ * object when the search failed). An item carrying `anthropic_blocks` came from Anthropic's
227
+ * own server-side call and maps back to those blocks verbatim (`srvtoolu_` id,
228
+ * `encrypted_content`, web_fetch), so the client can replay them on its next turn.
229
+ * Shared by the SSE, JSON and direct translation paths.
229
230
  */
230
231
  export function webSearchPairFromItem(item: Rec): {
231
- id: string; name: "web_search" | "web_fetch"; resultType: string; input: Rec; resultContent: unknown; completed: boolean;
232
+ id: string; name: string; resultType: string; input: Rec; resultContent: unknown; completed: boolean;
232
233
  } {
233
234
  const action = isRec(item.action) ? item.action : {};
234
- const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`;
235
235
  const completed = item.status !== "failed";
236
+ const { use, result } = isRec(item.anthropic_blocks) ? item.anthropic_blocks : {};
237
+ if (isRec(use) && isRec(result) && typeof use.id === "string" && isRec(use.input)
238
+ && (use.name === "web_search" || use.name === "web_fetch")
239
+ && result.tool_use_id === use.id && result.type === `${use.name}_tool_result`) {
240
+ return { id: use.id, name: use.name, resultType: result.type, input: use.input, resultContent: result.content, completed };
241
+ }
242
+ const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`;
243
+ // An item emitted before `anthropic_blocks` carried a server web_fetch as `fetch_result`.
236
244
  if (item.fetch_result !== undefined) {
237
245
  const input = { url: typeof action.url === "string" ? action.url : "" };
238
246
  return { id, name: "web_fetch", resultType: "web_fetch_tool_result", input, resultContent: item.fetch_result, completed };
@@ -26,6 +26,7 @@ import {
26
26
  } from "../server/proxy-liveness";
27
27
  import { directLocalHttpFetch } from "../server/direct-local-http";
28
28
  import type { RuntimePortState } from "../config/process-state";
29
+ import { loopbackFamilyAvailability } from "./loopback-family";
29
30
 
30
31
  const MAX_HINT_BYTES = 256 * 1024;
31
32
  // Far above any real Grok config; discovery must not stall startup when Grok sync is off.
@@ -128,13 +129,19 @@ function loopbackPort(raw: string | null): number | null {
128
129
  }
129
130
  }
130
131
 
132
+ type CrossHomeOwnerOptions = {
133
+ homeDir?: string;
134
+ io?: LivenessIo;
135
+ loopbackAvailabilityFn?: typeof loopbackFamilyAvailability;
136
+ };
137
+
131
138
  /** Returns only a different process with an identity-checked /healthz response. */
132
- export async function findCrossHomeOwner(options: { homeDir?: string; io?: LivenessIo } = {}): Promise<number | null> {
139
+ export async function findCrossHomeOwner(options: CrossHomeOwnerOptions = {}): Promise<number | null> {
133
140
  const verdict = await findCrossHomeOwnerDetailed(options);
134
141
  return verdict.kind === "owner" ? verdict.port : null;
135
142
  }
136
143
 
137
- export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io?: LivenessIo } = {}): Promise<CrossHomeOwnerVerdict> {
144
+ export async function findCrossHomeOwnerDetailed(options: CrossHomeOwnerOptions = {}): Promise<CrossHomeOwnerVerdict> {
138
145
  const io = options.io ?? {};
139
146
  const nowFn = io.nowFn ?? Date.now;
140
147
  const timeoutMs = io.timeoutMs ?? START_OWNERSHIP_LIVENESS.timeoutMs ?? 1_500;
@@ -205,6 +212,8 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io
205
212
  }
206
213
 
207
214
  const queue = [...candidates];
215
+ const availability = new Map<string, ReturnType<typeof loopbackFamilyAvailability>>();
216
+ const checkAvailability = options.loopbackAvailabilityFn ?? loopbackFamilyAvailability;
208
217
  const probed = new Set<number>();
209
218
  for (const port of queue) {
210
219
  if (probed.has(port)) continue;
@@ -225,6 +234,11 @@ export async function findCrossHomeOwnerDetailed(options: { homeDir?: string; io
225
234
  noteIndeterminate(port, "ownership discovery ran out of its shared time budget");
226
235
  break;
227
236
  }
237
+ // Disabled loopback IPv6 can route ::1 off-host and time out. An ephemeral
238
+ // local bind proves address absence without treating an available family's
239
+ // timeout as empty. Unknown bind failures retain the existing probes.
240
+ if (!availability.has(hostname)) availability.set(hostname, checkAvailability(hostname));
241
+ if (availability.get(hostname) === "unavailable") continue;
228
242
  const identity = await proxyIdentityAt(port, { hostname }, probeIo);
229
243
  if (identity === null) {
230
244
  // A null identity is not "free": distinguish a unanimous connect refusal