From 72bf3b3b8e3adf4f57b2e02d05881b0a26046fcd Mon Sep 17 00:00:00 2001 From: Koosha Pari Date: Thu, 2 Jul 2026 20:04:26 -0700 Subject: [PATCH 1/3] fix: keep Claude tool results adjacent --- .../translator/request/openai-to-claude.ts | 100 +++++++++++++----- .../unit/translator-openai-to-claude.test.ts | 64 +++++++++++ 2 files changed, 136 insertions(+), 28 deletions(-) diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 078d48e94aa..7012ceefe76 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -339,34 +339,7 @@ export function openaiToClaudeRequest(model, body, stream) { return true; }); - // Filter orphaned tool_result blocks whose tool_use_id has no matching tool_use - const allToolUseIds = new Set(); - for (const msg of result.messages) { - if (msg.role === "assistant" && Array.isArray(msg.content)) { - for (const block of msg.content) { - if (block.type === "tool_use" && block.id) { - allToolUseIds.add(String(block.id)); - } - } - } - } - for (const msg of result.messages) { - if (msg.role === "user" && Array.isArray(msg.content)) { - msg.content = msg.content.filter((block) => { - if (block.type === "tool_result" && block.tool_use_id) { - return allToolUseIds.has(String(block.tool_use_id)); - } - return true; - }); - } - } - // Remove user messages that became empty after orphan filtering - result.messages = result.messages.filter((msg) => { - if (msg.role === "user" && Array.isArray(msg.content) && msg.content.length === 0) { - return false; - } - return true; - }); + result.messages = enforceToolResultAdjacency(result.messages); // Add cache_control to last assistant message for (let i = result.messages.length - 1; i >= 0; i--) { @@ -502,6 +475,77 @@ export function openaiToClaudeRequest(model, body, stream) { return result; } +// Anthropic requires each user tool_result turn to immediately follow the +// assistant turn containing the matching tool_use. OpenAI-compatible clients can +// send intervening user text before a later role:"tool" message, so repair the +// ordering here and drop true orphan results. +function enforceToolResultAdjacency(messages: ClaudeMessage[]): ClaudeMessage[] { + const assistantByToolUseId = new Map(); + for (const msg of messages) { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (block.type === "tool_use" && block.id && !assistantByToolUseId.has(String(block.id))) { + assistantByToolUseId.set(String(block.id), msg); + } + } + } + + const resultsByAssistant = new Map>(); + const strippedMessages: ClaudeMessage[] = []; + + for (const msg of messages) { + if (msg.role !== "user" || !Array.isArray(msg.content)) { + strippedMessages.push(msg); + continue; + } + + const remainingBlocks: ClaudeContentBlock[] = []; + for (const block of msg.content) { + if (block.type !== "tool_result") { + remainingBlocks.push(block); + continue; + } + + const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : ""; + const assistant = toolUseId ? assistantByToolUseId.get(toolUseId) : undefined; + if (!assistant) continue; + + let grouped = resultsByAssistant.get(assistant); + if (!grouped) { + grouped = new Map(); + resultsByAssistant.set(assistant, grouped); + } + if (!grouped.has(toolUseId)) grouped.set(toolUseId, block); + } + + if (remainingBlocks.length > 0) { + strippedMessages.push({ ...msg, content: remainingBlocks }); + } + } + + const reordered: ClaudeMessage[] = []; + for (const msg of strippedMessages) { + reordered.push(msg); + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue; + + const grouped = resultsByAssistant.get(msg); + if (!grouped) continue; + + const adjacentResults: ClaudeContentBlock[] = []; + for (const block of msg.content) { + if (block.type !== "tool_use" || !block.id) continue; + const toolResult = grouped.get(String(block.id)); + if (toolResult) adjacentResults.push(toolResult); + } + + if (adjacentResults.length > 0) { + reordered.push({ role: "user", content: adjacentResults }); + } + } + + return reordered; +} + // Get content blocks from single message function getContentBlocksFromMessage( msg, diff --git a/tests/unit/translator-openai-to-claude.test.ts b/tests/unit/translator-openai-to-claude.test.ts index 70c3f1a4f23..6978a498a65 100644 --- a/tests/unit/translator-openai-to-claude.test.ts +++ b/tests/unit/translator-openai-to-claude.test.ts @@ -225,6 +225,70 @@ test("OpenAI -> Claude converts multimodal content, tool declarations, tool call }); }); +test("OpenAI -> Claude does not leave tool results separated from their tool use", () => { + const result = openaiToClaudeRequest( + "claude-4-sonnet", + { + messages: [ + { role: "user", content: "Start" }, + { + role: "assistant", + content: "Calling tool", + tool_calls: [ + { + id: "call_weather", + type: "function", + function: { + name: "weather.get", + arguments: '{"city":"Tokyo"}', + }, + }, + ], + }, + { role: "user", content: "Please wait before using that result." }, + { + role: "tool", + tool_call_id: "call_weather", + content: "20C", + }, + ], + }, + false + ); + + const toolResultIndex = result.messages.findIndex( + (message) => + message.role === "user" && + message.content.some( + (block) => block.type === "tool_result" && block.tool_use_id === "call_weather" + ) + ); + + assert.notEqual(toolResultIndex, -1, "expected the delayed tool_result to be preserved"); + const previousMessage = result.messages[toolResultIndex - 1]; + assert.equal(previousMessage?.role, "assistant"); + assert.ok( + previousMessage.content.some( + (block) => block.type === "tool_use" && block.id === "call_weather" + ), + "tool_result must immediately follow its matching tool_use" + ); + + const waitMessageIndex = result.messages.findIndex( + (message) => + message.role === "user" && + message.content.some( + (block) => + block.type === "text" && + block.text === "Please wait before using that result." + ) + ); + assert.ok( + waitMessageIndex > toolResultIndex, + "intervening user text should be moved after the repaired tool_result turn" + ); +}); + test("OpenAI -> Claude maps tool_choice and injects response_format instructions into system", () => { const schemaResult = openaiToClaudeRequest( "claude-4-sonnet", From 50f9529ac7267e2fa1e4918b02af12cdd36d3fbb Mon Sep 17 00:00:00 2001 From: Koosha Pari Date: Thu, 2 Jul 2026 20:28:28 -0700 Subject: [PATCH 2/3] refactor: split Claude tool result adjacency repair --- .../translator/request/openai-to-claude.ts | 72 +----------- .../openai-to-claude/toolResultAdjacency.ts | 106 ++++++++++++++++++ 2 files changed, 107 insertions(+), 71 deletions(-) create mode 100644 open-sse/translator/request/openai-to-claude/toolResultAdjacency.ts diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 7012ceefe76..1c380b5f333 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -8,6 +8,7 @@ import { safeParseJSON } from "../helpers/jsonUtil.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; import { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; +import { enforceToolResultAdjacency } from "./openai-to-claude/toolResultAdjacency.ts"; // Reasoning-effort levels Anthropic accepts on `output_config.effort`. Used to steer // adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget. @@ -475,77 +476,6 @@ export function openaiToClaudeRequest(model, body, stream) { return result; } -// Anthropic requires each user tool_result turn to immediately follow the -// assistant turn containing the matching tool_use. OpenAI-compatible clients can -// send intervening user text before a later role:"tool" message, so repair the -// ordering here and drop true orphan results. -function enforceToolResultAdjacency(messages: ClaudeMessage[]): ClaudeMessage[] { - const assistantByToolUseId = new Map(); - for (const msg of messages) { - if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue; - for (const block of msg.content) { - if (block.type === "tool_use" && block.id && !assistantByToolUseId.has(String(block.id))) { - assistantByToolUseId.set(String(block.id), msg); - } - } - } - - const resultsByAssistant = new Map>(); - const strippedMessages: ClaudeMessage[] = []; - - for (const msg of messages) { - if (msg.role !== "user" || !Array.isArray(msg.content)) { - strippedMessages.push(msg); - continue; - } - - const remainingBlocks: ClaudeContentBlock[] = []; - for (const block of msg.content) { - if (block.type !== "tool_result") { - remainingBlocks.push(block); - continue; - } - - const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : ""; - const assistant = toolUseId ? assistantByToolUseId.get(toolUseId) : undefined; - if (!assistant) continue; - - let grouped = resultsByAssistant.get(assistant); - if (!grouped) { - grouped = new Map(); - resultsByAssistant.set(assistant, grouped); - } - if (!grouped.has(toolUseId)) grouped.set(toolUseId, block); - } - - if (remainingBlocks.length > 0) { - strippedMessages.push({ ...msg, content: remainingBlocks }); - } - } - - const reordered: ClaudeMessage[] = []; - for (const msg of strippedMessages) { - reordered.push(msg); - if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue; - - const grouped = resultsByAssistant.get(msg); - if (!grouped) continue; - - const adjacentResults: ClaudeContentBlock[] = []; - for (const block of msg.content) { - if (block.type !== "tool_use" || !block.id) continue; - const toolResult = grouped.get(String(block.id)); - if (toolResult) adjacentResults.push(toolResult); - } - - if (adjacentResults.length > 0) { - reordered.push({ role: "user", content: adjacentResults }); - } - } - - return reordered; -} - // Get content blocks from single message function getContentBlocksFromMessage( msg, diff --git a/open-sse/translator/request/openai-to-claude/toolResultAdjacency.ts b/open-sse/translator/request/openai-to-claude/toolResultAdjacency.ts new file mode 100644 index 00000000000..9b40385df39 --- /dev/null +++ b/open-sse/translator/request/openai-to-claude/toolResultAdjacency.ts @@ -0,0 +1,106 @@ +type ClaudeContentBlock = Record; +type ClaudeMessage = { + role: string; + content: ClaudeContentBlock[]; +}; + +// Anthropic requires each user tool_result turn to immediately follow the +// assistant turn containing the matching tool_use. OpenAI-compatible clients can +// send intervening user text before a later role:"tool" message, so repair the +// ordering here and drop true orphan results. +export function enforceToolResultAdjacency(messages: ClaudeMessage[]): ClaudeMessage[] { + const assistantByToolUseId = indexAssistantToolUses(messages); + const resultsByAssistant = new Map(); + const strippedMessages: ClaudeMessage[] = []; + + for (const msg of messages) { + stripAndCollectToolResults( + msg, + assistantByToolUseId, + resultsByAssistant, + strippedMessages + ); + } + + return insertAdjacentToolResults(strippedMessages, resultsByAssistant); +} + +function indexAssistantToolUses(messages: ClaudeMessage[]): Map { + const assistantByToolUseId = new Map(); + for (const msg of messages) { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (block.type === "tool_use" && block.id && !assistantByToolUseId.has(String(block.id))) { + assistantByToolUseId.set(String(block.id), msg); + } + } + } + return assistantByToolUseId; +} + +function stripAndCollectToolResults( + msg: ClaudeMessage, + assistantByToolUseId: Map, + resultsByAssistant: Map, + strippedMessages: ClaudeMessage[] +): void { + if (msg.role !== "user" || !Array.isArray(msg.content)) { + strippedMessages.push(msg); + return; + } + + const remainingBlocks: ClaudeContentBlock[] = []; + for (const block of msg.content) { + if (block.type !== "tool_result") { + remainingBlocks.push(block); + } else { + collectMatchedToolResult(block, assistantByToolUseId, resultsByAssistant); + } + } + + if (remainingBlocks.length > 0) { + strippedMessages.push({ ...msg, content: remainingBlocks }); + } +} + +function collectMatchedToolResult( + block: ClaudeContentBlock, + assistantByToolUseId: Map, + resultsByAssistant: Map +): void { + const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : ""; + const assistant = toolUseId ? assistantByToolUseId.get(toolUseId) : undefined; + if (!assistant) return; + + const grouped = resultsByAssistant.get(assistant) ?? []; + if (grouped.some((toolResult) => toolResult.tool_use_id === toolUseId)) return; + + grouped.push(block); + resultsByAssistant.set(assistant, grouped); +} + +function insertAdjacentToolResults( + messages: ClaudeMessage[], + resultsByAssistant: Map +): ClaudeMessage[] { + const reordered: ClaudeMessage[] = []; + for (const msg of messages) { + reordered.push(msg); + const adjacentResults = orderedResultsForAssistant(msg, resultsByAssistant); + if (adjacentResults.length > 0) reordered.push({ role: "user", content: adjacentResults }); + } + return reordered; +} + +function orderedResultsForAssistant( + msg: ClaudeMessage, + resultsByAssistant: Map +): ClaudeContentBlock[] { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return []; + + const grouped = resultsByAssistant.get(msg) ?? []; + return msg.content.flatMap((block) => { + if (block.type !== "tool_use" || !block.id) return []; + return grouped.filter((toolResult) => toolResult.tool_use_id === String(block.id)); + }); +} From f388e002e7a42e6e52167f187814a59c1bb33e41 Mon Sep 17 00:00:00 2001 From: Koosha Pari Date: Thu, 2 Jul 2026 21:14:04 -0700 Subject: [PATCH 3/3] ci: rebaseline release file-size gate --- config/quality/file-size-baseline.json | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 69e8fbf2bcf..a6b8038f551 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_07_03_6035_release_merge": "Release/v3.8.44 merge into #6035: file-size gate was base-red after upstream release advanced. Frozen current inherited release counts only: ApiManagerPageClient 3017->3058, webProvidersA 805->809, OAuthModal 969->989, cliRuntime 1090->1100, deepseek-web.test 1081->1092. These are unrelated to the #6026 translator fix and remain frozen from this point.", "_rebaseline_2026_07_02_5798_release_green": "Release-green unblock #5798 / PR #5896 (2026-07-02): the quality.yml fast-gates job was base-red for EVERY PR->release (whole queue failing), from growth inherited via already-merged PRs — no offending PR branch left to fix. Prod frozen raised: AddApiKeyModal.tsx 869->905, providerPageHelpers.ts 996->1021, RequestLoggerV2.tsx 1316->1553, src/sse/services/auth.ts 2403->2405, antigravity.ts 1806->1813, base.ts 1502->1536 (1533 inherited + 3 lines from this PR's own typecheck:core fix in resolveBaseUrl), advancedTools.ts 1118->1120, accountFallback.ts 1783->1790, openai-to-kiro.ts 842->853, openai-responses.ts 1035->1092, stream.ts 2710->2727; new-above-cap frozen: webProvidersA.ts 805, tokenHealthCheck.ts 830. Test frozen raised: cc-compatible-provider 1179->1217, translator-openai-to-kiro 999->1088, web-cookie-providers-new 827->845; new-above-cap: response-sanitizer.test.ts 906. These files remain frozen (cannot grow further); the release captain's rebaseline-at-release supersedes this note.", "_rebaseline_2026_06_30_5552_flat_rate_cost": "Issue #5552 own growth: src/app/api/usage/analytics/route.ts 941->942 (+1 = the `flatRateAsZero: true` cost option at the existing computeUsageRowCost chokepoint, so subscription/cookie-web providers show $0 instead of an inflated per-token estimate in analytics). The flat-rate classifier (isFlatRateProvider + the provider-id set) lives in a new leaf src/lib/usage/flatRateProviders.ts (61 LOC, 1502 (+2 = import + the single `requestCredentials = withForcedResponsesUpstream(...)` const threaded through buildUrl/buildHeaders/applyConfiguredUserAgent/ccRequestDefaults/transformRequest at the existing fetch-loop chokepoint), open-sse/executors/default.ts 876->877 (+1 = the `_omnirouteForceResponsesUpstream` short-circuit in the buildUrl `/responses` vs `/chat/completions` decision), tests/unit/executor-default-base.test.ts 1477->1523 (+46 = the new regression test that asserts a Responses-shaped MCP request routes to /responses for openai-compatible providers). The detection helpers (shouldForceResponsesUpstream/withForcedResponsesUpstream/isRecord, ~50 LOC) were EXTRACTED out of base.ts into a new leaf open-sse/executors/forceResponsesUpstream.ts (60 LOC, 969 (gate units). #5193 (+~4: remote paste instruction shown for all remote incl. Google + its rationale comment) and #5203 (+~5: handleManualSubmit credential-blob branch + button guard; submit logic extracted to oauthBlobSubmit.ts to minimize). Frozen set to the SUM so either merge order passes. Cohesive at the existing manual-submit chokepoint.", - "src/shared/components/OAuthModal.tsx": 969, + "src/shared/components/OAuthModal.tsx": 989, "src/shared/components/RequestLoggerV2.tsx": 1553, "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, "src/shared/constants/pricing.ts": 1662, "src/shared/constants/providers.ts": 3276, "src/shared/constants/sidebarVisibility.ts": 1198, - "src/shared/services/cliRuntime.ts": 1090, + "src/shared/services/cliRuntime.ts": 1100, "src/shared/validation/schemas.ts": 2523, "_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 7913017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui.", - "src/lib/providers/validation/webProvidersA.ts": 805, + "src/lib/providers/validation/webProvidersA.ts": 809, "src/lib/tokenHealthCheck.ts": 830 }, "testCap": 800, @@ -283,7 +284,7 @@ "tests/unit/db-core-init.test.ts": 877, "tests/unit/db-migration-runner.test.ts": 1491, "tests/unit/db-settings-crud.test.ts": 941, - "tests/unit/deepseek-web.test.ts": 1081, + "tests/unit/deepseek-web.test.ts": 1092, "tests/unit/executor-antigravity.test.ts": 942, "tests/unit/executor-codex.test.ts": 1347, "tests/unit/executor-default-base.test.ts": 1523,