Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/fix-gemini-thought-signature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cloudflare/ai-chat": patch
---

Fix Gemini "missing thought_signature" error when using client-side tools with `addToolOutput`.

The server-side message builder (`applyChunkToParts`) was dropping `providerMetadata` from tool-input stream chunks instead of storing it as `callProviderMetadata` on tool UIMessage parts. When `convertToModelMessages` later read the persisted messages for the continuation call, `callProviderMetadata` was undefined, so Gemini never received its `thought_signature` back and rejected the request.

- Preserve `callProviderMetadata` (mapped from stream `providerMetadata`) on tool parts in `tool-input-start`, `tool-input-available`, and `tool-input-error` handlers — both create and update paths
- Preserve `providerExecuted` on tool parts (used by `convertToModelMessages` for provider-executed tools like Gemini code execution)
- Preserve `title` on tool parts (tool display name)
- Add `providerExecuted` to `StreamChunkData` type explicitly
- Add 13 regression tests covering all affected codepaths
43 changes: 40 additions & 3 deletions packages/ai-chat/src/message-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export type StreamChunkData = {
/** Approval ID for tools with needsApproval */
approvalId?: string;
providerMetadata?: Record<string, unknown>;
/** Whether the tool was executed by the provider (e.g. Gemini code execution) */
providerExecuted?: boolean;
/** Payload for data-* parts (developer-defined typed JSON) */
data?: unknown;
/** When true, data parts are ephemeral and not persisted to message.parts */
Expand Down Expand Up @@ -179,7 +181,14 @@ export function applyChunkToParts(
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
state: "input-streaming",
input: undefined
input: undefined,
...(chunk.providerExecuted != null
? { providerExecuted: chunk.providerExecuted }
: {}),
...(chunk.providerMetadata != null
? { callProviderMetadata: chunk.providerMetadata }
: {}),
...(chunk.title != null ? { title: chunk.title } : {})
} as MessagePart);
return true;
}
Expand All @@ -201,13 +210,29 @@ export function applyChunkToParts(
const p = existing as Record<string, unknown>;
p.state = "input-available";
p.input = chunk.input;
if (chunk.providerExecuted != null) {
p.providerExecuted = chunk.providerExecuted;
}
if (chunk.providerMetadata != null) {
p.callProviderMetadata = chunk.providerMetadata;
}
if (chunk.title != null) {
p.title = chunk.title;
}
} else {
parts.push({
type: `tool-${chunk.toolName}`,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
state: "input-available",
input: chunk.input
input: chunk.input,
...(chunk.providerExecuted != null
? { providerExecuted: chunk.providerExecuted }
: {}),
...(chunk.providerMetadata != null
? { callProviderMetadata: chunk.providerMetadata }
: {}),
...(chunk.title != null ? { title: chunk.title } : {})
} as MessagePart);
}
return true;
Expand All @@ -221,14 +246,26 @@ export function applyChunkToParts(
p.state = "output-error";
p.errorText = chunk.errorText;
p.input = chunk.input;
if (chunk.providerExecuted != null) {
p.providerExecuted = chunk.providerExecuted;
}
if (chunk.providerMetadata != null) {
p.callProviderMetadata = chunk.providerMetadata;
}
} else {
parts.push({
type: `tool-${chunk.toolName}`,
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
state: "output-error",
input: chunk.input,
errorText: chunk.errorText
errorText: chunk.errorText,
...(chunk.providerExecuted != null
? { providerExecuted: chunk.providerExecuted }
: {}),
...(chunk.providerMetadata != null
? { callProviderMetadata: chunk.providerMetadata }
: {})
} as MessagePart);
}
return true;
Expand Down
8 changes: 4 additions & 4 deletions packages/ai-chat/src/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1108,10 +1108,10 @@ export function useAgentChat<
const chunkData = JSON.parse(data.body);

// Apply chunk to parts using shared parser.
// Handles text, reasoning, file, source, tool, step, and data-* chunks.
// Unrecognized types (tool-input-start, tool-input-delta, etc.)
// are intermediate states — the final state is captured by
// tool-input-available / tool-output-available.
// Handles text, reasoning, file, source, tool lifecycle
// (including streaming: tool-input-start, tool-input-delta,
// tool-input-available, tool-output-available), step, and
// data-* chunks.
const handled = applyChunkToParts(
activeMsg.parts as MessageParts,
chunkData
Expand Down
205 changes: 205 additions & 0 deletions packages/ai-chat/src/tests/message-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,211 @@ describe("applyChunkToParts", () => {
});
});

describe("tool provider metadata (callProviderMetadata, providerExecuted, title)", () => {
it("tool-input-available preserves callProviderMetadata from providerMetadata", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-available",
toolCallId: "call_1",
toolName: "askQuestion",
input: { question: "What is your name?" },
providerMetadata: {
google: { thoughtSignature: "sig_abc123" }
}
});
const part = parts[0] as Record<string, unknown>;
expect(part.callProviderMetadata).toEqual({
google: { thoughtSignature: "sig_abc123" }
});
});

it("tool-input-available update path preserves callProviderMetadata", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-start",
toolCallId: "call_1",
toolName: "askQuestion"
});
applyChunkToParts(parts, {
type: "tool-input-available",
toolCallId: "call_1",
toolName: "askQuestion",
input: { question: "Name?" },
providerMetadata: {
google: { thoughtSignature: "sig_xyz" }
}
});
const part = parts[0] as Record<string, unknown>;
expect(part.callProviderMetadata).toEqual({
google: { thoughtSignature: "sig_xyz" }
});
});

it("tool-input-start preserves callProviderMetadata", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-start",
toolCallId: "call_1",
toolName: "askQuestion",
providerMetadata: {
google: { thoughtSignature: "sig_start" }
}
});
const part = parts[0] as Record<string, unknown>;
expect(part.callProviderMetadata).toEqual({
google: { thoughtSignature: "sig_start" }
});
});

it("tool-input-error preserves callProviderMetadata (create path)", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-error",
toolCallId: "call_1",
toolName: "askQuestion",
errorText: "Parse error",
providerMetadata: {
google: { thoughtSignature: "sig_err" }
}
});
const part = parts[0] as Record<string, unknown>;
expect(part.callProviderMetadata).toEqual({
google: { thoughtSignature: "sig_err" }
});
});

it("tool-input-error preserves callProviderMetadata (update path)", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-start",
toolCallId: "call_1",
toolName: "askQuestion"
});
applyChunkToParts(parts, {
type: "tool-input-error",
toolCallId: "call_1",
toolName: "askQuestion",
errorText: "Parse error",
providerMetadata: {
google: { thoughtSignature: "sig_err2" }
}
});
const part = parts[0] as Record<string, unknown>;
expect(part.callProviderMetadata).toEqual({
google: { thoughtSignature: "sig_err2" }
});
});

it("does not set callProviderMetadata when providerMetadata is absent", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-available",
toolCallId: "call_1",
toolName: "getWeather",
input: { city: "London" }
});
const part = parts[0] as Record<string, unknown>;
expect(part.callProviderMetadata).toBeUndefined();
});

it("tool-input-available preserves providerExecuted", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-available",
toolCallId: "call_1",
toolName: "codeExec",
input: { code: "1+1" },
providerExecuted: true
} as StreamChunkData);
const part = parts[0] as Record<string, unknown>;
expect(part.providerExecuted).toBe(true);
});

it("tool-input-available update path preserves providerExecuted", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-start",
toolCallId: "call_1",
toolName: "codeExec"
});
applyChunkToParts(parts, {
type: "tool-input-available",
toolCallId: "call_1",
toolName: "codeExec",
input: { code: "1+1" },
providerExecuted: true
} as StreamChunkData);
const part = parts[0] as Record<string, unknown>;
expect(part.providerExecuted).toBe(true);
});

it("tool-input-available preserves title", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-available",
toolCallId: "call_1",
toolName: "getWeather",
input: { city: "London" },
title: "Get Weather"
});
const part = parts[0] as Record<string, unknown>;
expect(part.title).toBe("Get Weather");
});

it("tool-input-available update path preserves title", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-start",
toolCallId: "call_1",
toolName: "getWeather"
});
applyChunkToParts(parts, {
type: "tool-input-available",
toolCallId: "call_1",
toolName: "getWeather",
input: { city: "London" },
title: "Get Weather"
});
const part = parts[0] as Record<string, unknown>;
expect(part.title).toBe("Get Weather");
});

it("tool-input-start preserves providerExecuted and title", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-start",
toolCallId: "call_1",
toolName: "codeExec",
providerExecuted: true,
title: "Code Execution"
} as StreamChunkData);
const part = parts[0] as Record<string, unknown>;
expect(part.providerExecuted).toBe(true);
expect(part.title).toBe("Code Execution");
});

it("preserves all three fields together on tool-input-available", () => {
const parts = makeParts();
applyChunkToParts(parts, {
type: "tool-input-available",
toolCallId: "call_1",
toolName: "askQuestion",
input: { question: "Name?" },
providerMetadata: {
google: { thoughtSignature: "sig_full" }
},
providerExecuted: false,
title: "Ask Question"
} as StreamChunkData);
const part = parts[0] as Record<string, unknown>;
expect(part.callProviderMetadata).toEqual({
google: { thoughtSignature: "sig_full" }
});
expect(part.providerExecuted).toBe(false);
expect(part.title).toBe("Ask Question");
});
});

describe("metadata and message-level chunks", () => {
it("returns false for 'start' chunk (caller handles metadata)", () => {
const parts = makeParts();
Expand Down
Loading