fix(session): normalize tool call blocks for cross-provider compatibility

When users switch providers mid-session (e.g., from Google Antigravity to
Anthropic), the session history may contain tool call blocks in different
formats:

- Anthropic / pi-ai: `{ type: "toolCall", arguments: {} }`
- Google Antigravity: `{ type: "toolUse", input: {} }`

This mismatch causes API validation failures like "input: Field required"
when the new provider's serializer encounters the old format.

This fix adds `normalizeToolCallBlocks()` to the session sanitization
pipeline, which converts all tool call variants (toolCall, toolUse,
functionCall) to the canonical `{ type: "toolCall", arguments: {} }` shape
before sending to any provider.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Brendan Smith-Elion 2026-01-30 10:17:26 -05:00
parent da71eaebd2
commit f9c98ca045

View File

@ -305,6 +305,62 @@ export function applyGoogleTurnOrderingFix(params: {
return { messages: sanitized, didPrepend }; return { messages: sanitized, didPrepend };
} }
/**
* Normalizes tool call content blocks in assistant messages so they always use
* the canonical shape: `{ type: "toolCall", arguments: ... }`.
*
* Different providers persist tool calls in different shapes:
* - Anthropic / pi-ai: `{ type: "toolCall", arguments: {} }`
* - Google Antigravity: `{ type: "toolUse", input: {} }`
*
* When a user switches providers mid-session the old shape can reach the new
* provider's serializer unchanged, causing validation failures (e.g. Anthropic
* rejects requests when the `input` field is missing on a `tool_use` block).
*/
function normalizeToolCallBlocks(messages: AgentMessage[]): AgentMessage[] {
let changed = false;
const out: AgentMessage[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object" || (msg as { role?: unknown }).role !== "assistant") {
out.push(msg);
continue;
}
const assistant = msg as Extract<AgentMessage, { role: "assistant" }>;
const content = assistant.content;
if (!Array.isArray(content)) {
out.push(msg);
continue;
}
let contentChanged = false;
const nextContent = content.map((block) => {
if (!block || typeof block !== "object") return block;
const rec = block as unknown as Record<string, unknown>;
const type = rec.type;
if (type !== "toolCall" && type !== "toolUse" && type !== "functionCall") return block;
// Ensure `arguments` is populated — prefer existing `arguments`, fall back to `input`, default to {}.
const args = rec.arguments ?? rec.input ?? {};
if (type === "toolCall" && rec.arguments !== undefined) return block; // already canonical
contentChanged = true;
return { ...rec, type: "toolCall", arguments: args };
});
if (contentChanged) {
changed = true;
out.push({ ...assistant, content: nextContent } as typeof assistant);
} else {
out.push(msg);
}
}
return changed ? out : messages;
}
export async function sanitizeSessionHistory(params: { export async function sanitizeSessionHistory(params: {
messages: AgentMessage[]; messages: AgentMessage[];
modelApi?: string | null; modelApi?: string | null;
@ -336,6 +392,12 @@ export async function sanitizeSessionHistory(params: {
? sanitizeToolUseResultPairing(sanitizedThinking) ? sanitizeToolUseResultPairing(sanitizedThinking)
: sanitizedThinking; : sanitizedThinking;
// Normalize tool call content blocks so every provider serializer sees a consistent shape.
// Session history may contain blocks from different providers (e.g. Google Antigravity uses
// { type: "toolUse", input: {} } while Anthropic expects { type: "toolCall", arguments: {} }).
// Without this, switching providers mid-session causes "input: Field required" API rejections.
const normalizedTools = normalizeToolCallBlocks(repairedTools);
const isOpenAIResponsesApi = const isOpenAIResponsesApi =
params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses"; params.modelApi === "openai-responses" || params.modelApi === "openai-codex-responses";
const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId); const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId);
@ -350,8 +412,8 @@ export async function sanitizeSessionHistory(params: {
: false; : false;
const sanitizedOpenAI = const sanitizedOpenAI =
isOpenAIResponsesApi && modelChanged isOpenAIResponsesApi && modelChanged
? downgradeOpenAIReasoningBlocks(repairedTools) ? downgradeOpenAIReasoningBlocks(normalizedTools)
: repairedTools; : normalizedTools;
if (hasSnapshot && (!priorSnapshot || modelChanged)) { if (hasSnapshot && (!priorSnapshot || modelChanged)) {
appendModelSnapshot(params.sessionManager, { appendModelSnapshot(params.sessionManager, {