diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 1e1e1fc7789..1410eedd195 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -710,6 +710,22 @@ When routing Claude Code to the canonical ChatGPT Codex backend, OpenCodex remov A combo can also advance after an intact HTTP 400 `invalid_request_error` that specifically rejects `user`, reports `unsupported_value` for `reasoning.effort`/`reasoning_effort`, or reports `param: input` with an exact model-scoped `does not support image inputs` rejection. A route that refuses the request's media with code `unsupported_input_modality`, such as image input sent to a CLI provider route without a verified multimodal contract, advances the same way. This is a mismatch for that request, not evidence that the target is unhealthy, so it records no cooldown. This compatibility recovery does not silently change `none` into a different effort or broaden this exception to arbitrary invalid requests. Policy refusals, cancellation and already-committed output remain non-replayable. A single-target request still returns an unresolved upstream rejection. +## Tool calls stay on one target + +When a model asks your client to run a tool, the request that returns the tool result finishes the +same assistant turn. OpenCodex sends that request to the target that issued the tool call, as long +as the target is still enabled, still in the combo, and out of cooldown. This holds for every +strategy, including JEV, which skips its decision for that request. The next user message chooses a +target normally. + +Tracking uses the client's thread or session id, so a client that sends neither gets a normal pick. +If the original target is unavailable or fails, the combo fails over as usual, and the new target +receives the earlier tool calls in a form it accepts. DeepSeek thinking models get a one-space +reasoning placeholder for a tool call that another model issued without reasoning. Gemini gets +Google's documented signature placeholder. Anthropic gets a plain tool call and result. Earlier +versions could send DeepSeek a bare tool call here and fail with `The reasoning_text in the +thinking mode must be passed back to the API`. + ## Default reasoning effort `defaultEffort` supplies a configured effort when the selected target has a known, nonempty supported ladder. With the default `defaultEffortMode: "fallback"`, an explicit caller effort keeps precedence. `defaultEffortMode: "force"` overrides a valid caller effort with the configured default; it requires a valid, non-null `defaultEffort` and can increase cost and latency. Force mode is an explicit operator choice through combo configuration or management. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7ad4c7d793a..b9e26de1bff 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -873,6 +873,7 @@ "deepinfra-provider.test.ts": "providers", "deepseek-inbound-wire.test.ts": "providers", "deepseek-reasoning-replay-gaps.test.ts": "providers", + "deepseek-bare-tool-call-replay.test.ts": "providers", "deepseek-reasoning-replay.test.ts": "providers", "deepseek-responses-item-id-repair.test.ts": "providers", "management-desktop-compatibility-routes.test.ts": "server", "management-desktop-compatibility-runtime-routes.test.ts": "server", "server-desktop-compatibility-startup.test.ts": "server", "management-desktop-compatibility-settings.test.ts": "server", @@ -988,6 +989,7 @@ "google-models-listing.test.ts": "adapters/google", "google-output-clamp.test.ts": "adapters/google", "google-provider-metadata-roundtrip.test.ts": "adapters/google", + "google-ai-studio-foreign-call-signature.test.ts": "adapters/google", "google-signature-history-roundtrip.test.ts": "adapters/google", "google-strict-tool-validated-mode.test.ts": "adapters/google", "google-structured-output.test.ts": "adapters/google", @@ -1650,6 +1652,7 @@ "server-combo-failover-e2e.test.ts": "server", "server-combo-held-response.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", + "server-combo-tool-loop-continuation.test.ts": "server", "server-combo-zero-output-failover.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-antigravity-web-search-validation.test.ts": "server", "server-google-antigravity-oauth-429-budget.test.ts": "server", diff --git a/src/adapters/google.ts b/src/adapters/google.ts index ed55b5870f5..bbab7285784 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1254,6 +1254,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy); reportToolSchemaLoss(compiled.toolSchemaLossReport); restoreGoogleToolName = compiled.restoreToolName; + // A functionCall issued by another model (a combo hop, #7298) has no Gemini signature, and + // Gemini 3 refuses the turn without one. The same Gemini-only sentinel CCA and Vertex + // apply; a real signature carried by history always wins. + if (Array.isArray((compiled.body as { contents?: unknown[] }).contents)) { + applyAntigravityThoughtSignatureFallback(routedModelId, (compiled.body as { contents: unknown[] }).contents); + } return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; }, diff --git a/src/adapters/openai-responses/reasoning.ts b/src/adapters/openai-responses/reasoning.ts index 1682d0dd247..2fd591aea06 100644 --- a/src/adapters/openai-responses/reasoning.ts +++ b/src/adapters/openai-responses/reasoning.ts @@ -163,7 +163,74 @@ export function sanitizeReasoningInputContent( }); if (blankedPlaintext) opts?.onPlaintextReasoningBlanked?.(); - return changed ? { ...raw, input } : body; + // Runs after the per-item pass so an item it emptied has already been backfilled, and a + // placeholder is only invented for a turn that carries no reasoning item at all. + const withTurnReasoning = opts?.requirePlaintextReasoning === true + ? backfillBareToolCallTurnReasoning(input) + : input; + if (withTurnReasoning !== input) changed = true; + return changed ? { ...raw, input: withTurnReasoning } : body; +} + +/** Assistant-authored history items: what DeepSeek merges into one assistant message. */ +function isAssistantTurnItem(item: unknown): boolean { + if (!isPlainObject(item) || typeof item.type !== "string") return false; + if (item.type === "reasoning") return true; + if (item.type === "message") return item.role === "assistant"; + return item.type.endsWith("_call"); +} + +function isClientToolCallItem(item: unknown): boolean { + return isPlainObject(item) + && (item.type === "function_call" || item.type === "custom_tool_call" || item.type === "local_shell_call"); +} + +/** + * Give every assistant tool-call turn that has NO reasoning item a minimal plaintext one (#7298). + * + * A Combo can serve the turn that issued a tool call on one model and the continuation on + * another. When the issuing model was a GPT turn that produced no reasoning item (or the item + * was dropped upstream), the strict destination sees a bare `function_call` and DeepSeek's + * thinking mode refuses the whole request with `The reasoning_text in the thinking mode must be + * passed back to the API`. The per-item backfill above cannot help, because there is no item to + * backfill. This is the Responses-wire twin of the Chat adapter's `reasoning_content: " "` + * placeholder (#5421): the same single-space text, never fabricated reasoning, inserted at the + * head of the turn so it precedes the call exactly where a native turn's reasoning would be. + * + * A turn is a maximal run of assistant-authored items; any user message or tool output ends it. + * Turns that already carry a reasoning item, and turns with no client tool call, are untouched. + */ +export function backfillBareToolCallTurnReasoning(input: unknown[]): unknown[] { + const bareTurnStarts = new Set(); + let turnStart = -1; + let turnHasReasoning = false; + let turnHasToolCall = false; + const closeTurn = (): void => { + if (turnStart >= 0 && turnHasToolCall && !turnHasReasoning) bareTurnStarts.add(turnStart); + turnStart = -1; + turnHasReasoning = false; + turnHasToolCall = false; + }; + for (let index = 0; index < input.length; index++) { + const item = input[index]; + if (!isAssistantTurnItem(item)) { + closeTurn(); + continue; + } + if (turnStart < 0) turnStart = index; + if ((item as Record).type === "reasoning") turnHasReasoning = true; + if (isClientToolCallItem(item)) turnHasToolCall = true; + } + closeTurn(); + if (bareTurnStarts.size === 0) return input; + const result: unknown[] = []; + for (let index = 0; index < input.length; index++) { + if (bareTurnStarts.has(index)) { + result.push({ type: "reasoning", summary: [], content: [{ type: "reasoning_text", text: " " }] }); + } + result.push(input[index]); + } + return result; } /** diff --git a/src/server/responses/combo-session-recall.ts b/src/server/responses/combo-session-recall.ts index b3af31f1c44..80c2c79c277 100644 --- a/src/server/responses/combo-session-recall.ts +++ b/src/server/responses/combo-session-recall.ts @@ -110,6 +110,31 @@ export function recallComboForLane( return entry.responseModel === model ? entry.comboId : undefined; } +/** + * The concrete target that completed the lane's last turn for `comboId`, under the same + * freshness and ownership rules as `recallComboForLane`. A lane answered by another combo, or + * whose target left the combo or was disabled, recalls nothing. + */ +export function recallComboTargetForLane( + config: OcxConfig, + lane: string | undefined, + comboId: string, +): Pick | undefined { + if (!lane || !comboId) return undefined; + const entry = recall.get(lane); + if (!entry || entry.comboId !== comboId) return undefined; + const combo = getCombo(config, entry.comboId); + const provider = config.providers[entry.target.provider]; + if (Date.now() - entry.at >= RECALL_TTL_MS + || !Object.hasOwn(config.providers, entry.target.provider) + || !provider || provider.disabled === true + || !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) { + deleteEntry(lane); + return undefined; + } + return { provider: entry.target.provider, model: entry.target.model }; +} + /** * Periodic expiry. Without it a lane that is never read again and never touched by a config * reconciliation holds its entry for the life of the process: the existing TTL is only diff --git a/src/server/responses/combo-tool-loop-pin.ts b/src/server/responses/combo-tool-loop-pin.ts new file mode 100644 index 00000000000..95c88721ff2 --- /dev/null +++ b/src/server/responses/combo-tool-loop-pin.ts @@ -0,0 +1,70 @@ +/** + * Combo target pinning for an in-flight tool loop (#7298). + * + * A combo picks a target per request. A tool loop is several requests that together form one + * assistant turn: the model emits a call, the client runs it, and the next request carries the + * result back. Re-picking between those requests hands the second half of a turn to a model that + * did not produce the first half. That is wasteful at best (a cold prompt cache, a different + * plan) and a hard failure at worst: DeepSeek's thinking mode refuses a tool continuation whose + * call carries no reasoning it can read, and a GPT turn never leaves one. + * + * So a continuation whose input ends in a tool output keeps the target that completed the lane's + * previous turn, as long as that target is still selectable right now: enabled, still in the + * combo, out of cooldown and passing every per-request eligibility filter the caller supplies. + * When it is not, normal selection (and normal failover) runs, and the strict serializers make + * the foreign history acceptable instead: see `backfillBareToolCallTurnReasoning` and the Chat + * adapter's reasoning placeholder. + * + * A new user message is never pinned; re-deciding per user turn is what a combo is for. + */ +import { pickComboTarget, targetKey, type ComboPick } from "../../combos"; +import type { NormalizedComboTarget } from "../../combos/types"; +import type { OcxConfig } from "../../types"; +import { recallComboTargetForLane } from "./combo-session-recall"; + +export const COMBO_TOOL_LOOP_PIN_REASON = "combo-tool-loop-pin"; + +function isToolOutputItem(item: unknown): boolean { + if (!item || typeof item !== "object" || Array.isArray(item)) return false; + const type = (item as { type?: unknown }).type; + return typeof type === "string" && type.endsWith("_output"); +} + +/** + * Whether this request resumes an assistant turn by returning tool results: its Responses + * `input` (or Chat `messages`) ends with a tool output. + */ +export function isToolLoopContinuation(body: unknown): boolean { + if (!body || typeof body !== "object" || Array.isArray(body)) return false; + const { input, messages } = body as { input?: unknown; messages?: unknown }; + if (Array.isArray(input)) return input.length > 0 && isToolOutputItem(input[input.length - 1]); + if (Array.isArray(messages) && messages.length > 0) { + const last = messages[messages.length - 1]; + return !!last && typeof last === "object" && (last as { role?: unknown }).role === "tool"; + } + return false; +} + +/** + * The pick that keeps a tool loop on its previous target, or undefined to select normally. + * Uses a non-mutating preview so a pinned request never disturbs round-robin rotation state. + */ +export function toolLoopPinnedPick(args: { + config: OcxConfig; + comboId: string; + lane: string | undefined; + body: unknown; + eligible: (target: NormalizedComboTarget) => boolean; + now?: number; +}): ComboPick | undefined { + if (!isToolLoopContinuation(args.body)) return undefined; + const previous = recallComboTargetForLane(args.config, args.lane, args.comboId); + if (!previous) return undefined; + const previousKey = targetKey(previous); + const pick = pickComboTarget(args.config, args.comboId, { + eligible: target => targetKey(target) === previousKey && args.eligible(target), + now: args.now, + preview: true, + }); + return pick ? { ...pick, attempted: [previousKey] } : undefined; +} diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index c96244d1718..1addd41a366 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -98,6 +98,7 @@ import type { CodexAuthContext } from "../../codex/auth-context"; import type { ResponsesTerminalStatus } from "../../bridge"; import { beginRequestAttempt, sealRequestAttemptIdentity, finishRequestAttempt } from "../request-log"; import { rememberComboForLane } from "./combo-session-recall"; +import { COMBO_TOOL_LOOP_PIN_REASON, toolLoopPinnedPick } from "./combo-tool-loop-pin"; import { runTurnAdapterSseResponses } from "./core-lifetime"; import { normalizePersistedJevDecision } from "../../usage/jev-stats"; import { @@ -589,7 +590,19 @@ export async function executeComboResponses( waitForCooldownMs: combo.waitForCooldownMs, abortSignal: options.abortSignal, }); - let pick = await pickWithWait({ + // An in-flight tool loop stays on the target that issued its calls while that target is still + // selectable (#7298). Override routes never record a lane target, so they never pin either. + const toolLoopPin = options.compactionRoutingOverride || options.mainHardLockFallbackOverride + ? undefined + : toolLoopPinnedPick({ + config, + comboId, + lane: sessionLaneIdFromRequest(req.headers), + body, + eligible: target => (meteredAllowed || !target.metered) && targetEligible(target), + now: initialNow, + }); + let pick = toolLoopPin ?? await pickWithWait({ eligible: targetEligible, now: initialNow, }); @@ -625,7 +638,7 @@ export async function executeComboResponses( : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible)); } let jevDecision: JevDecision | undefined; - if (combo.strategy === "jev") { + if (combo.strategy === "jev" && !toolLoopPin) { const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now()); const first = choices[0]; if (!first) { @@ -715,8 +728,11 @@ export async function executeComboResponses( // One immutable combo selection trace, before any child dispatch; child // adoption below must never replace it with a concrete child route trace. const decision = comboRouteDecisionTrace(config, comboId, pick, requestedModel); - logCtx.routeDecision = options.memoryModelPhase - ? { ...decision, selected: { ...decision.selected, reason: memoryModelRouteReason(options.memoryModelPhase) } } + const selectedReason = options.memoryModelPhase + ? memoryModelRouteReason(options.memoryModelPhase) + : toolLoopPin ? COMBO_TOOL_LOOP_PIN_REASON : undefined; + logCtx.routeDecision = selectedReason + ? { ...decision, selected: { ...decision.selected, reason: selectedReason } } : decision; const originalReasoning = body && typeof body === "object" && !Array.isArray(body) diff --git a/structure/providers/google.md b/structure/providers/google.md index 050021d8257..a26de75debb 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -115,6 +115,10 @@ Vertex prefers Codex's opaque `prompt_cache_key` for session identity and falls first-user-message derivation for clients that omit it; only the fixed hash is retained. Both streaming and non-streaming responses feed the store; request compilation happens before replay so matching uses the provider-visible tool name. +A first `functionCall` that still has no real signature after replay receives the Gemini-only +`skip_thought_signature_validator` sentinel through `applyAntigravityThoughtSignatureFallback`, on +Antigravity, Vertex and AI Studio alike. AI Studio gained it for combo hops that hand Gemini a call +another model issued (#7298); non-Gemini model ids never receive it. `src/adapters/openai-responses/passthrough.ts` removes item-level `extra_content` through `stripProviderInputItemMetadata` in `src/adapters/openai-responses/request-strips.ts` only for diff --git a/structure/transports/native-reasoning-retention.md b/structure/transports/native-reasoning-retention.md index 1d4a1bfdde2..58444b25b0e 100644 --- a/structure/transports/native-reasoning-retention.md +++ b/structure/transports/native-reasoning-retention.md @@ -17,3 +17,12 @@ Account retention is experimental forwarding permission, not evidence that the backend accepts or reuses another caller's reasoning. `src/server/responses/account-change-state.ts` continues removing account-bound continuation references and foreign reasoning item ids; uploaded-file refusal stays independent. Opaque-blob rejection recovery and its rejection memo override retention. When retention actually kept ciphertext across a change, `src/server/responses/passthrough-dispatch.ts` also inspects the zero-output stream prelude, so a refusal ChatGPT sends inside a 200 stream (a Codex WebSocket `error` frame or `response.failed`) takes the same single sanitized rebuild as an HTTP 4xx refusal. Previously stripped ciphertext is never restored in a later recovery leg. Claude-native per-blob replay tags remain authoritative: retention never permits a blob whose durable endpoint or generation-bearing credential fails its tag check. Only reasoning `encrypted_content` is covered. Native compaction cleanup has its own internal flag and preserves the existing identity-change behavior. Direct native `/v1/responses/compact` sanitization is unchanged. API-key providers, arbitrary forward aliases and translated destinations receive no new permission. The configuration and management surface is described above. + +## Combo tool-loop continuation + +A combo picks a target per request, but a tool loop spans several requests that form one assistant turn. Re-picking between them handed DeepSeek a GPT-issued `function_call` with no reasoning item, which its thinking mode refuses with HTTP 400 (#7298). Two layers make a mid-loop switch safe. + +`src/server/responses/combo-tool-loop-pin.ts` pins a request whose `input` (or Chat `messages`) ends with a tool output to the target that completed the lane's previous turn, recalled from `src/server/responses/combo-session-recall.ts` under its existing TTL, ownership and lane rules. The pin applies only while that target passes every current filter: provider enabled, still a combo member, out of cooldown, metered consent and per-request eligibility. It uses a non-mutating pick preview, so rotation state is unchanged, skips the JEV decision, and records `combo-tool-loop-pin` as the selected route reason. A new user message is never pinned, and override routes (compaction, main hard-lock fallback) neither record nor read a lane target. + +When the pinned target is unavailable or fails, normal selection and failover run, and each strict serializer accepts foreign history instead of refusing it. `sanitizeReasoningInputContent` in `src/adapters/openai-responses/reasoning.ts` gives every assistant tool-call turn with no reasoning item a `reasoning_text: " "` item at its head on destinations with `requiresPlaintextReasoningReplay`. That is the Responses twin of the Chat adapter's `reasoning_content: " "` placeholder (#5421): a fixed single space, never invented reasoning. Gemini on AI Studio receives the validator sentinel for an unsigned first call, as Antigravity and Vertex already did ([Google provider](../providers/google.md#google-tool-call-thought-signature-replay)). Anthropic receives the call as an ordinary `tool_use`/`tool_result` pair with no thinking block. The opaque-only eligibility rule in `core-replay.ts` is unchanged. Coverage: `tests/server/server-combo-tool-loop-continuation.test.ts`, `tests/providers/deepseek-bare-tool-call-replay.test.ts` and `tests/adapters/google/google-ai-studio-foreign-call-signature.test.ts`. + diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index ebff7e8c852..af8c784af31 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -168,8 +168,8 @@ Identity and consumer aggregation follow the [account attribution contract](../d When a serving-route change leaves a tool-bearing history whose reasoning has neither plaintext nor usable opaque content, a target that requires plaintext reasoning replay is ineligible. Failover continues to the next target; exhausting the eligible targets returns `400 target_incompatible`. -Opaque reasoning minted by another provider or account is never forwarded, and plaintext is never -fabricated. +Foreign opaque reasoning is never forwarded or rewritten into plaintext; a turn with no reasoning item +gets only the fixed placeholder in [tool-loop continuation](native-reasoning-retention.md#combo-tool-loop-continuation). ## Combo streaming commit boundary diff --git a/tests/adapters/google/google-ai-studio-foreign-call-signature.test.ts b/tests/adapters/google/google-ai-studio-foreign-call-signature.test.ts new file mode 100644 index 00000000000..2e6d9fffb69 --- /dev/null +++ b/tests/adapters/google/google-ai-studio-foreign-call-signature.test.ts @@ -0,0 +1,59 @@ +/** + * #7298: a combo hop can hand Gemini on AI Studio a functionCall another model issued. It carries + * no Gemini thought signature, and Gemini 3 refuses an unsigned call in the current turn. AI Studio + * now applies the same Gemini-only validator sentinel that Antigravity and Vertex already apply. + */ +import { describe, expect, test } from "bun:test"; +import { createGoogleAdapter as createGoogleAdapterProduction } from "../../../src/adapters/google"; +import { THOUGHT_SIGNATURE_BYPASS } from "../../../src/adapters/google-antigravity-replay"; +import { parseRequest } from "../../../src/responses/parser"; +import type { OcxProviderConfig } from "../../../src/types"; +import { withTestTranslatorBudget } from "../../helpers/translator-budget"; + +const createGoogleAdapter = (...args: Parameters) => + withTestTranslatorBudget(createGoogleAdapterProduction(...args)); + +const aiStudio = { + adapter: "google", + googleMode: "ai-studio", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "ai-studio-test-key", +} as OcxProviderConfig; + +const SIGNATURE = "CiQAx-history-thought-signature-0123456789abcdef"; + +async function modelParts(model: string, call: Record): Promise>> { + const parsed = parseRequest({ + model, + tools: [{ type: "function", name: "get_weather", parameters: { type: "object", properties: {} } }], + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "weather?" }] }, + { type: "function_call", call_id: "call_1", name: "get_weather", arguments: "{}", ...call }, + { type: "function_call_output", call_id: "call_1", output: "rain" }, + ], + }); + const built = await createGoogleAdapter(aiStudio).buildRequest(parsed); + const body = JSON.parse(String(built.body)) as { contents: Array<{ role: string; parts: Array> }> }; + return body.contents.find(content => content.role === "model")!.parts; +} + +describe("AI Studio foreign function call signature (#7298)", () => { + test("a call issued by another model gets the validator sentinel on Gemini", async () => { + const parts = await modelParts("gemini-4-argon", {}); + expect(parts[0]!.functionCall).toBeDefined(); + expect(parts[0]!.thoughtSignature).toBe(THOUGHT_SIGNATURE_BYPASS); + }); + + test("a real signature carried by history wins over the sentinel", async () => { + const parts = await modelParts("gemini-4-argon", { + extra_content: { google: { thought_signature: SIGNATURE } }, + }); + expect(parts[0]!.thoughtSignature).toBe(SIGNATURE); + }); + + test("a non-Gemini model on AI Studio never receives the Gemini-only sentinel", async () => { + const parts = await modelParts("gemma-4-27b-it", {}); + expect(parts[0]!.thoughtSignature).toBeUndefined(); + }); +}); + diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 938dfc19934..d6b47681015 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -654,7 +654,7 @@ "decision-discovery.test.ts": "server", "server-jev-model-decision-e2e.test.ts": "server", "server-jev-model-quota-budget.test.ts": "server", "decision-routes.test.ts": "server", "deepinfra-provider.test.ts": "providers", "deepseek-inbound-wire.test.ts": "providers", "deepseek-reasoning-replay-gaps.test.ts": "providers", - "deepseek-reasoning-replay.test.ts": "providers", "deepseek-responses-item-id-repair.test.ts": "providers", + "deepseek-bare-tool-call-replay.test.ts": "providers", "deepseek-reasoning-replay.test.ts": "providers", "deepseek-responses-item-id-repair.test.ts": "providers", "desktop-3p-guard.test.ts": "clients", "desktop-3p-removal.test.ts": "clients", "desktop-3p-write-files.test.ts": "clients", "desktop-3p.test.ts": "clients", "desktop-app-restart.test.ts": "clients", @@ -725,7 +725,7 @@ "google-errors.test.ts": "adapters/google", "google-hardening.test.ts": "adapters/google", "google-models-listing.test.ts": "adapters/google", "google-output-clamp.test.ts": "adapters/google", "google-provider-metadata-roundtrip.test.ts": "adapters/google", - "google-signature-history-roundtrip.test.ts": "adapters/google", + "google-ai-studio-foreign-call-signature.test.ts": "adapters/google", "google-signature-history-roundtrip.test.ts": "adapters/google", "google-strict-tool-validated-mode.test.ts": "adapters/google", "google-structured-output.test.ts": "adapters/google", "google-tool-result-adjacency.test.ts": "adapters/google", "google-tool-schema-contract.test.ts": "adapters/google", "google-tool-schema.test.ts": "adapters/google", "google-vertex-http.test.ts": "adapters/google", @@ -1133,7 +1133,7 @@ "server-auth-scoped-quota.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", "server-combo-cooldown-fallback.test.ts": "server", "server-combo-failover-e2e.test.ts": "server", - "server-combo-held-response.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", + "server-combo-held-response.test.ts": "server", "server-combo-reasoning-replay-eligibility.test.ts": "server", "server-combo-tool-loop-continuation.test.ts": "server", "server-combo-zero-output-failover.test.ts": "server", "server-google-antigravity-oauth-401-replay.test.ts": "server", "server-antigravity-web-search-validation.test.ts": "server", "server-google-antigravity-oauth-429-budget.test.ts": "server", "server-gui-bundle-freshness.test.ts": "server", "server-images-bodyless-content-length.test.ts": "server", "server-images-pool-admission.test.ts": "server", diff --git a/tests/providers/deepseek-bare-tool-call-replay.test.ts b/tests/providers/deepseek-bare-tool-call-replay.test.ts new file mode 100644 index 00000000000..ffcc958efa9 --- /dev/null +++ b/tests/providers/deepseek-bare-tool-call-replay.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction, sanitizeReasoningInputContent } from "../../src/adapters/openai-responses"; +import { backfillBareToolCallTurnReasoning } from "../../src/adapters/openai-responses/reasoning"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const createResponsesPassthroughAdapter = (...args: Parameters) => + withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +type Item = Record; +const PLACEHOLDER = { type: "reasoning", summary: [], content: [{ type: "reasoning_text", text: " " }] }; +const user = (text: string): Item => ({ type: "message", role: "user", content: [{ type: "input_text", text }] }); +const call = (id: string): Item => ({ type: "function_call", call_id: id, name: "get_weather", arguments: "{}" }); +const output = (id: string): Item => ({ type: "function_call_output", call_id: id, output: "rain" }); + +describe("backfillBareToolCallTurnReasoning (#7298)", () => { + test("a bare tool-call turn gets one placeholder reasoning item at its head", () => { + const input = [user("weather?"), call("c1"), output("c1")]; + expect(backfillBareToolCallTurnReasoning(input)).toEqual([user("weather?"), PLACEHOLDER, call("c1"), output("c1")]); + }); + + test("every bare turn of a multi-step loop is backfilled; parallel calls share one item", () => { + const text: Item = { type: "message", role: "assistant", content: [{ type: "output_text", text: "checking" }] }; + const input = [user("go"), text, call("a"), call("b"), output("a"), output("b"), call("c"), output("c")]; + expect(backfillBareToolCallTurnReasoning(input)).toEqual([ + user("go"), PLACEHOLDER, text, call("a"), call("b"), output("a"), output("b"), PLACEHOLDER, call("c"), output("c"), + ]); + }); + + test("a turn that already carries reasoning, and a turn with no tool call, are untouched", () => { + const reasoning: Item = { type: "reasoning", summary: [], content: [{ type: "reasoning_text", text: "plan" }] }; + const answer: Item = { type: "message", role: "assistant", content: [{ type: "output_text", text: "done" }] }; + const input = [user("go"), reasoning, call("a"), output("a"), answer, user("thanks")]; + expect(backfillBareToolCallTurnReasoning(input)).toBe(input); + }); + + test("custom tool calls count as tool calls", () => { + const custom: Item = { type: "custom_tool_call", call_id: "p1", name: "apply_patch", input: "*** Begin" }; + const customOutput: Item = { type: "custom_tool_call_output", call_id: "p1", output: "ok" }; + expect(backfillBareToolCallTurnReasoning([custom, customOutput])).toEqual([PLACEHOLDER, custom, customOutput]); + }); + + test("the sanitizer applies it only for plaintext-required destinations", () => { + const body = { model: "m", input: [user("go"), call("a"), output("a")] }; + expect(sanitizeReasoningInputContent(body, { preserveRawReasoningContent: true })).toBe(body); + const strict = sanitizeReasoningInputContent(body, { requirePlaintextReasoning: true }) as { input: Item[] }; + expect(strict.input[1]).toEqual(PLACEHOLDER); + expect(body.input).toHaveLength(3); + }); +}); + +describe("DeepSeek Responses wire receives a replayable reasoning item for a foreign tool call (#7298)", () => { + function build(input: Item[], providerName: string): Item[] { + const provider = { ...providerConfigSeed(getProviderRegistryEntry(providerName)!), apiKey: "sk-test" }; + enrichProviderFromRegistry(providerName, provider); + const built = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "deepseek-v4-flash", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: "deepseek-v4-flash", input, tools: [{ type: "function", name: "get_weather", parameters: { type: "object", properties: {} } }] }, + } as Parameters["buildRequest"]>[0], { headers: new Headers() }); + return (JSON.parse(String(built.body)) as { input: Item[] }).input; + } + + test("a GPT-issued bare call reaches DeepSeek preceded by reasoning_text", () => { + const wire = build([user("weather?"), call("c1"), output("c1")], "deepseek"); + expect(wire.map(item => item.type)).toEqual(["message", "reasoning", "function_call", "function_call_output"]); + expect(wire[1]!.content).toEqual([{ type: "reasoning_text", text: " " }]); + }); +}); + diff --git a/tests/providers/deepseek-inbound-wire.test.ts b/tests/providers/deepseek-inbound-wire.test.ts index 0db58c6a6f6..72b6b9c2f17 100644 --- a/tests/providers/deepseek-inbound-wire.test.ts +++ b/tests/providers/deepseek-inbound-wire.test.ts @@ -423,7 +423,10 @@ describe("the inbound scope survives the handleResponses replay", () => { { inboundTransport: "websocket" }, ); await followup.text(); + // The relayed turn carried no reasoning item, so the strict wire gets the fixed placeholder + // ahead of the call (#7298), exactly as the Chat wire injects `reasoning_content: " "`. expect(requestBodies[1]?.input).toEqual([ + { type: "reasoning", summary: [], content: [{ type: "reasoning_text", text: " " }] }, { type: "function_call", call_id: "call_ws", name: "probe", arguments: "{\"text\":\"OK\"}" }, { type: "function_call_output", call_id: "call_ws", output: "OK" }, ]); diff --git a/tests/responses/responses-stateless-dangling-call-repair.test.ts b/tests/responses/responses-stateless-dangling-call-repair.test.ts index 8e8c52ba15c..224e37688db 100644 --- a/tests/responses/responses-stateless-dangling-call-repair.test.ts +++ b/tests/responses/responses-stateless-dangling-call-repair.test.ts @@ -16,6 +16,7 @@ import type { OcxConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; const MODEL = "deepseek-v4-flash"; +const PLACEHOLDER = JSON.stringify({ type: "reasoning", summary: [], content: [{ type: "reasoning_text", text: " " }] }); let releaseSpendHome: (() => void) | undefined; function deepseekProvider(): ReturnType & { apiKey: string } { @@ -52,7 +53,14 @@ describe("stateless Responses wire repairs orphaned tool calls", () => { config, { model: "", provider: "" }, ); - return requests[0] ?? { url: "", body: {} }; + const sent = requests[0] ?? { url: "", body: {} }; + // A bare call turn also gains the fixed plaintext reasoning placeholder on this strict wire + // (#7298, covered in deepseek-bare-tool-call-replay.test.ts). These cases pin call/output + // repair, so read the input without that one item; real reasoning items are kept. + if (Array.isArray(sent.body.input)) { + sent.body.input = (sent.body.input as unknown[]).filter(item => JSON.stringify(item) !== PLACEHOLDER); + } + return sent; } test("synthesizes a function_call_output after a dangling function_call", async () => { diff --git a/tests/server/server-combo-tool-loop-continuation.test.ts b/tests/server/server-combo-tool-loop-continuation.test.ts new file mode 100644 index 00000000000..ebfbaf4cced --- /dev/null +++ b/tests/server/server-combo-tool-loop-continuation.test.ts @@ -0,0 +1,336 @@ +/** + * #7298: a combo that re-picks its target in the middle of a tool loop handed DeepSeek a bare + * `function_call` (issued by a GPT turn that left no reasoning item), and DeepSeek's thinking mode + * refused every such request with HTTP 400. Two layers now cover it: + * - a tool-output continuation stays on the lane's previous target while it is selectable; + * - when a switch is unavoidable (failover), each strict serializer makes the foreign history + * acceptable: a plaintext reasoning placeholder on the DeepSeek Responses and Chat wires, + * an intact tool_use/tool_result pair with no fabricated thinking for Anthropic. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; +import { clearKeyCooldowns } from "../../src/providers/key-failover"; +import { clearReasoningReplayCacheForTests } from "../../src/responses/reasoning-replay-cache"; +import { + clearResponseStateForTests, + flushResponseState, + responseStatePersistPendingForTests, +} from "../../src/responses/state"; +import { handleResponses } from "../../src/server/responses"; +import { clearComboRecallForTests } from "../../src/server/responses/combo-session-recall"; +import { COMBO_TOOL_LOOP_PIN_REASON, isToolLoopContinuation } from "../../src/server/responses/combo-tool-loop-pin"; +import type { RequestLogContext } from "../../src/server/request-log"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +type Item = Record; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +const servers: Array> = []; +let releaseSpendHome: (() => void) | undefined; + +function resetState(): void { + clearComboSelectionState(); + clearComboRecallForTests(); + clearComboTargetCooldowns(); + clearKeyCooldowns(); + clearReasoningReplayCacheForTests(); +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-combo-tool-loop-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-combo-tool-loop-")); + process.env.OPENCODEX_HOME = testDir; + releaseSpendHome = acquireOwnedSpendHome(); + resetState(); + clearResponseStateForTests(); +}); + +afterEach(async () => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + let responseStatePending = true; + try { + for (const server of servers.splice(0)) await server.stop(true); + await flushResponseState(); + responseStatePending = responseStatePersistPendingForTests(); + } finally { + clearResponseStateForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); + resetState(); + } + expect(responseStatePending).toBe(false); +}); + +function serve(handler: (request: Request) => Response | Promise) { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: handler }); + servers.push(server); + return server; +} + +const baseUrl = (server: ReturnType, suffix = "/v1"): string => + `${server.url.toString().replace(/\/$/, "")}${suffix}`; + +function provider(adapter: string, url: string, extra: Partial = {}): OcxProviderConfig { + return { adapter, baseUrl: url, allowPrivateNetwork: true, authMode: "key", apiKey: `key-${adapter}`, ...extra }; +} + +const strictResponses = (url: string): OcxProviderConfig => provider("openai-responses", url, { + preserveResponsesReasoningContent: true, + requiresAdjacentResponsesToolResults: true, + statelessResponses: true, +}); + +const DEEPSEEK_REFUSAL = "The `reasoning_text` in the thinking mode must be passed back to the API."; + +/** DeepSeek's thinking-mode rule: every assistant tool-call turn carries readable reasoning. */ +function deepSeekRefusal(input: Item[]): Response | undefined { + let turnHasReasoning = false; + for (const item of input) { + const assistant = item.type === "reasoning" || item.type === "function_call" + || (item.type === "message" && item.role === "assistant"); + if (!assistant) { turnHasReasoning = false; continue; } + if (item.type === "reasoning") { + const content = item.content as Array<{ type?: string; text?: string }> | undefined; + turnHasReasoning = !!content?.some(part => part.type === "reasoning_text" && !!part.text); + } + if (item.type === "function_call" && !turnHasReasoning) { + return Response.json({ error: { message: DEEPSEEK_REFUSAL, type: "invalid_request_error", code: "invalid_request_error" } }, { status: 400 }); + } + } + return undefined; +} + +function responsesBody(model: string, output: Item[]): Item { + return { + id: `resp-${model}-${crypto.randomUUID()}`, + object: "response", + status: "completed", + model, + output, + usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 }, + }; +} + +const answer = (text: string): Item => ({ + id: `msg_${crypto.randomUUID()}`, type: "message", role: "assistant", status: "completed", + content: [{ type: "output_text", text, annotations: [] }], +}); +const issuedCall: Item = { id: "fc_1", type: "function_call", status: "completed", call_id: "call_1", name: "get_weather", arguments: "{}" }; +const busy = () => Response.json({ error: { type: "server_error", code: "upstream_server_error", message: "busy" } }, { status: 500 }); + +const tools = [{ type: "function", name: "get_weather", description: "Get weather", parameters: { type: "object", properties: {} } }]; +const userMessage = (text: string): Item => ({ type: "message", role: "user", content: [{ type: "input_text", text }] }); +/** The #7298 continuation: a GPT turn's bare call (no reasoning item) and its result. */ +const bareContinuation: Item[] = [ + userMessage("weather in Seoul?"), + { type: "function_call", call_id: "call_1", name: "get_weather", arguments: "{}" }, + { type: "function_call_output", call_id: "call_1", output: "rain" }, +]; + +function combo(providers: OcxConfig["providers"], targets: Array<{ provider: string; model: string }>, extra: Record = {}): OcxConfig { + return { + port: 0, + defaultProvider: Object.keys(providers)[0]!, + providers, + combos: { auto: { strategy: "failover", targets, ...extra } }, + } as OcxConfig; +} + +async function post(config: OcxConfig, input: unknown, headers: Record = {}, logCtx: RequestLogContext = { model: "", provider: "" }): Promise { + return handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify({ model: "combo/auto", input, tools, stream: false, store: false }), + }), config, logCtx); +} + +describe("isToolLoopContinuation", () => { + test("only a request that ends in a tool output resumes a turn", () => { + expect(isToolLoopContinuation({ input: bareContinuation })).toBe(true); + expect(isToolLoopContinuation({ input: [...bareContinuation, userMessage("thanks")] })).toBe(false); + expect(isToolLoopContinuation({ input: [{ type: "custom_tool_call_output", call_id: "p", output: "ok" }] })).toBe(true); + expect(isToolLoopContinuation({ input: "hello" })).toBe(false); + expect(isToolLoopContinuation({ messages: [{ role: "user", content: "x" }, { role: "tool", content: "y" }] })).toBe(true); + }); +}); + +describe("combo tool-loop continuation safety (#7298)", () => { + test("a Sol-issued bare tool call that fails over to DeepSeek Responses is accepted, not 400", async () => { + const sol = serve(busy); + const deepseekBodies: Item[] = []; + const deepseek = serve(async request => { + const body = await request.json() as Item; + deepseekBodies.push(body); + return deepSeekRefusal(body.input as Item[]) + ?? Response.json(responsesBody("deepseek-flash", [answer("It is raining.")])); + }); + const config = combo({ + primary: provider("openai-responses", baseUrl(sol)), + strict: strictResponses(baseUrl(deepseek)), + }, [{ provider: "primary", model: "gpt-6.1-sol" }, { provider: "strict", model: "deepseek-flash" }]); + + const response = await post(config, bareContinuation); + + expect(await response.text()).toContain("It is raining."); + expect(response.status).toBe(200); + const wire = deepseekBodies[0]!.input as Item[]; + expect(wire.map(item => item.type)).toEqual(["message", "reasoning", "function_call", "function_call_output"]); + expect(wire[1]!.content).toEqual([{ type: "reasoning_text", text: " " }]); + }); + + test("a tool-output continuation stays on the target that issued the call; the next user turn re-picks", async () => { + let solHits = 0; + const sol = serve(async request => { + solHits += 1; + const body = await request.json() as Item; + const input = body.input as Item[]; + return Response.json(responsesBody("gpt-6.1-sol", input.at(-1)?.type === "function_call_output" ? [answer("Rain today.")] : [issuedCall])); + }); + let deepseekHits = 0; + const deepseek = serve(async request => { + deepseekHits += 1; + const body = await request.json() as Item; + return deepSeekRefusal(body.input as Item[]) + ?? Response.json(responsesBody("deepseek-flash", [answer("You're welcome.")])); + }); + // Round-robin with stickyLimit 1 moves to the other target after every success, which is + // exactly the re-pick that broke #7298. + const config = combo({ + primary: provider("openai-responses", baseUrl(sol)), + strict: strictResponses(baseUrl(deepseek)), + }, [{ provider: "primary", model: "gpt-6.1-sol" }, { provider: "strict", model: "deepseek-flash" }], { strategy: "round-robin", stickyLimit: 1 }); + const lane = { "thread-id": "thread-7298" }; + + const first = await post(config, [userMessage("weather in Seoul?")], lane); + expect(first.status).toBe(200); + expect(await first.text()).toContain("get_weather"); + + const continuationLog: RequestLogContext = { model: "", provider: "" }; + const continuation = await post(config, bareContinuation, lane, continuationLog); + expect(continuation.status).toBe(200); + expect(await continuation.text()).toContain("Rain today."); + expect(solHits).toBe(2); + expect(deepseekHits).toBe(0); + expect(continuationLog.routeDecision?.selected?.reason).toBe(COMBO_TOOL_LOOP_PIN_REASON); + + // A new user turn is not pinned; the strict target accepts the foreign history it inherits. + const nextTurn = await post(config, [ + ...bareContinuation, + answer("Rain today."), + userMessage("thanks"), + ], lane); + expect(nextTurn.status).toBe(200); + expect(await nextTurn.text()).toContain("You're welcome."); + expect(deepseekHits).toBe(1); + }); + + test("a continuation on another lane is not pinned to this lane's target", async () => { + let solHits = 0; + const sol = serve(() => { + solHits += 1; + return Response.json(responsesBody("gpt-6.1-sol", [issuedCall])); + }); + let deepseekHits = 0; + const deepseek = serve(async request => { + deepseekHits += 1; + const body = await request.json() as Item; + return deepSeekRefusal(body.input as Item[]) ?? Response.json(responsesBody("deepseek-flash", [answer("ok")])); + }); + const config = combo({ + primary: provider("openai-responses", baseUrl(sol)), + strict: strictResponses(baseUrl(deepseek)), + }, [{ provider: "primary", model: "gpt-6.1-sol" }, { provider: "strict", model: "deepseek-flash" }], { strategy: "round-robin", stickyLimit: 1 }); + + await (await post(config, [userMessage("go")], { "thread-id": "lane-a" })).text(); + const other = await post(config, bareContinuation, { "thread-id": "lane-b" }); + + expect(other.status).toBe(200); + expect(solHits).toBe(1); + expect(deepseekHits).toBe(1); + }); + + test("a pinned target that fails mid-loop fails over, and the strict target gets replayable history", async () => { + let solFails = false; + const sol = serve(() => solFails ? busy() : Response.json(responsesBody("gpt-6.1-sol", [issuedCall]))); + const deepseek = serve(async request => { + const body = await request.json() as Item; + return deepSeekRefusal(body.input as Item[]) ?? Response.json(responsesBody("deepseek-flash", [answer("Failover answer.")])); + }); + const config = combo({ + primary: provider("openai-responses", baseUrl(sol)), + strict: strictResponses(baseUrl(deepseek)), + }, [{ provider: "primary", model: "gpt-6.1-sol" }, { provider: "strict", model: "deepseek-flash" }]); + const lane = { "thread-id": "thread-failover" }; + + await (await post(config, [userMessage("weather?")], lane)).text(); + solFails = true; + const continuation = await post(config, bareContinuation, lane); + + expect(continuation.status).toBe(200); + expect(await continuation.text()).toContain("Failover answer."); + }); + + test("DeepSeek on the Chat wire receives the reasoning_content placeholder for a foreign call", async () => { + const sol = serve(busy); + let chatBody: { messages?: Item[] } | undefined; + const deepseek = serve(async request => { + chatBody = await request.json() as { messages?: Item[] }; + return Response.json({ + id: "chatcmpl-1", object: "chat.completion", model: "deepseek-v4-flash", + choices: [{ index: 0, message: { role: "assistant", content: "Chat answer." }, finish_reason: "stop" }], + usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, + }); + }); + const config = combo({ + primary: provider("openai-responses", baseUrl(sol)), + strict: provider("openai-chat", baseUrl(deepseek)), + }, [{ provider: "primary", model: "gpt-6.1-sol" }, { provider: "strict", model: "deepseek-v4-flash" }]); + + const response = await post(config, bareContinuation); + + expect(response.status).toBe(200); + const assistant = chatBody?.messages?.find(message => message.role === "assistant" && Array.isArray(message.tool_calls)); + expect(assistant?.reasoning_content).toBe(" "); + }); + + test("Anthropic receives the foreign call as an intact tool_use/tool_result pair with no fabricated thinking", async () => { + const sol = serve(busy); + let anthropicBody: { messages?: Array<{ role: string; content: unknown }> } | undefined; + const anthropic = serve(async request => { + anthropicBody = await request.json() as typeof anthropicBody; + return Response.json({ + id: "msg_1", type: "message", role: "assistant", model: "claude-sonnet-4-5", + content: [{ type: "text", text: "Claude answer." }], stop_reason: "end_turn", + usage: { input_tokens: 2, output_tokens: 3 }, + }); + }); + const config = combo({ + primary: provider("openai-responses", baseUrl(sol)), + claudex: provider("anthropic", baseUrl(anthropic, ""), { proxy: "direct" }), + }, [{ provider: "primary", model: "gpt-6.1-sol" }, { provider: "claudex", model: "claude-sonnet-4-5" }]); + + const response = await post(config, bareContinuation); + + expect(response.status).toBe(200); + const messages = anthropicBody?.messages ?? []; + const assistant = messages.find(message => message.role === "assistant"); + const blocks = assistant?.content as Array<{ type: string; id?: string }>; + expect(blocks.map(block => block.type)).toEqual(["tool_use"]); + const toolResult = messages.at(-1)?.content as Array<{ type: string; tool_use_id?: string }>; + expect(toolResult[0]).toMatchObject({ type: "tool_result", tool_use_id: blocks[0]!.id }); + }); +}); +