diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index b0b4767b8a..616d81d6aa 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -217,6 +217,7 @@ interface ParserState { readonly usage?: Usage readonly lifecycle: Lifecycle.State readonly reasoningSignature?: string + readonly textSignature?: string } // ============================================================================= @@ -328,7 +329,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR if (!ProviderShared.supportsContent(part, ["text", "reasoning", "tool-call"])) return yield* ProviderShared.unsupportedContent("Gemini", "assistant", ["text", "reasoning", "tool-call"]) if (part.type === "text") { - parts.push({ text: part.text }) + parts.push({ text: part.text, thoughtSignature: thoughtSignature(part.providerMetadata) }) continue } if (part.type === "reasoning") { @@ -540,14 +541,16 @@ const finish = (state: ParserState): ReadonlyArray => { if (finishReason === undefined && state.usage === undefined) return [] const events: LLMEvent[] = [] - const lifecycle = state.reasoningSignature - ? Lifecycle.reasoningEnd( - state.lifecycle, - events, - "reasoning-0", - googleMetadata({ thoughtSignature: state.reasoningSignature }), - ) - : state.lifecycle + let lifecycle = state.lifecycle + if (state.reasoningSignature !== undefined) + lifecycle = Lifecycle.reasoningEnd( + lifecycle, + events, + "reasoning-0", + googleMetadata({ thoughtSignature: state.reasoningSignature }), + ) + if (state.textSignature !== undefined) + lifecycle = Lifecycle.textEnd(lifecycle, events, "text-0", googleMetadata({ thoughtSignature: state.textSignature })) Lifecycle.finish(lifecycle, events, { reason: { normalized: @@ -579,10 +582,14 @@ const step = (state: ParserState, event: GeminiEvent) => { let lifecycle = nextState.lifecycle let nextToolCallId = nextState.nextToolCallId let reasoningSignature = nextState.reasoningSignature + let textSignature = nextState.textSignature for (const part of candidate.content.parts) { - if ("thoughtSignature" in part && part.thoughtSignature && "thought" in part && part.thought) - reasoningSignature = part.thoughtSignature + const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined + // Gemini attaches replay signatures to thought parts, visible text, or function calls; + // each block kind must retain the signature attached to its own parts. + if (signature !== undefined && "thought" in part && part.thought) reasoningSignature = signature + else if (signature !== undefined && "text" in part) textSignature = signature if ("text" in part && part.text.length > 0) { if (part.thought) { lifecycle = Lifecycle.reasoningDelta( @@ -590,7 +597,7 @@ const step = (state: ParserState, event: GeminiEvent) => { events, "reasoning-0", part.text, - part.thoughtSignature ? googleMetadata({ thoughtSignature: part.thoughtSignature }) : undefined, + signature ? googleMetadata({ thoughtSignature: signature }) : undefined, ) continue } @@ -600,7 +607,14 @@ const step = (state: ParserState, event: GeminiEvent) => { "reasoning-0", reasoningSignature ? googleMetadata({ thoughtSignature: reasoningSignature }) : undefined, ) - lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", part.text) + lifecycle = Lifecycle.textDelta( + lifecycle, + events, + "text-0", + part.text, + textSignature ? googleMetadata({ thoughtSignature: textSignature }) : undefined, + ) + textSignature = undefined continue } @@ -637,6 +651,7 @@ const step = (state: ParserState, event: GeminiEvent) => { lifecycle, nextToolCallId, reasoningSignature, + textSignature, finishReason: candidate.finishReason ?? nextState.finishReason, }, events, diff --git a/packages/ai/src/protocols/utils/lifecycle.ts b/packages/ai/src/protocols/utils/lifecycle.ts index f415e895ea..45668eeaf0 100644 --- a/packages/ai/src/protocols/utils/lifecycle.ts +++ b/packages/ai/src/protocols/utils/lifecycle.ts @@ -21,9 +21,15 @@ export const textStart = (state: State, events: LLMEvent[], id: string, provider return { ...stepped, text: new Set([...stepped.text, id]) } } -export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { +export const textDelta = ( + state: State, + events: LLMEvent[], + id: string, + text: string, + providerMetadata?: ProviderMetadata, +): State => { const started = textStart(state, events, id) - events.push(LLMEvent.textDelta({ id, text })) + events.push(LLMEvent.textDelta({ id, text, providerMetadata })) return started } diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index c22ada6bce..01bd5a2281 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -905,6 +905,61 @@ describe("Gemini route", () => { }), ) + it.effect("preserves thoughtSignature on visible text parts", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] }, + finishReason: "STOP", + }, + ], + }) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const delta = response.events.find((event) => event.type === "text-delta") + expect(delta).toMatchObject({ + id: "text-0", + text: "All done.", + providerMetadata: { google: { thoughtSignature: "text_sig" } }, + }) + + const prepared = yield* compileRequest( + LLM.request({ + model, + messages: [Message.assistant([{ type: "text", text: "All done.", providerMetadata: delta?.providerMetadata }])], + }), + ) + expect(prepared.body.contents).toEqual([ + { role: "model", parts: [{ text: "All done.", thoughtSignature: "text_sig" }] }, + ]) + }), + ) + + it.effect("flushes a trailing empty signed text part at block close", () => + Effect.gen(function* () { + const body = sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [{ text: "Working." }, { text: "", thoughtSignature: "tail_sig" }], + }, + finishReason: "STOP", + }, + ], + }) + const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body))) + const delta = response.events.find((event) => event.type === "text-delta") + const end = response.events.find((event) => event.type === "text-end") + + expect(delta).toMatchObject({ id: "text-0", text: "Working.", providerMetadata: undefined }) + expect(end).toMatchObject({ + id: "text-0", + providerMetadata: { google: { thoughtSignature: "tail_sig" } }, + }) + }), + ) + it.effect("replays unsigned Gemini 3 tool calls with the validator bypass sentinel", () => Effect.gen(function* () { const prepared = yield* compileRequest( diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index cbf42ac24d..11cb9eed23 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -152,7 +152,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider { type: "text", text: item.text, - providerMetadata: sameProvider ? providerMetadata(providerMetadataKey, item.state) : undefined, + // Text can carry provider-bound state (e.g. Gemini thought signatures), + // which is only replayable against the model that produced it. + providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined, }, ] if (item.type === "reasoning") @@ -167,6 +169,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider : item.text.length > 0 ? [{ type: "text", text: item.text }] : [] + // Call-side metadata is model-scoped proof of generation (Gemini thought + // signatures, OpenAI encrypted reasoning): only the producing model may + // replay it. const reuseToolProviderMetadata = reuseProviderMetadata || (sameModel && item.executed === true && (item.state.status === "completed" || item.state.status === "error")) @@ -175,8 +180,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider reuseToolProviderMetadata ? providerMetadata(providerMetadataKey, item.providerState) : undefined, ) if (item.executed !== true) return [call] - // Hosted result payloads are provider-format state, not model state: - // replay must survive a model switch within the same provider. + // Hosted tools (e.g. google_search) run inside the provider, so their + // result payload (`providerResultState`) is provider-format data rather + // than model-scoped proof: it stays replayable across models of the same + // provider. After a model switch, echo only that payload — never fall + // back to `providerState`, whose call-side values are bound to the old + // model. const result = toolResult( item, reuseToolProviderMetadata diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 9adfeb371b..dde3d8876a 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -1020,7 +1020,7 @@ Recent work ]) }) - test("preserves assistant text provider state across same-provider model changes and failures", () => { + test("drops assistant text provider state across model changes and failures", () => { const messages = toLLMMessages( [ SessionMessage.Assistant.make({ @@ -1042,6 +1042,36 @@ Recent work Model.Ref.make({ id: Model.ID.make("new"), providerID: Provider.ID.make("provider") }), ) + expect(messages[0]?.content).toEqual([ + { + type: "text", + text: "Checking.", + providerMetadata: undefined, + }, + ]) + }) + + test("preserves assistant text provider state for the same model", () => { + const messages = toLLMMessages( + [ + SessionMessage.Assistant.make({ + id: id("assistant-phase"), + type: "assistant", + agent: build, + model: { id: Model.ID.make("same"), providerID: Provider.ID.make("provider") }, + content: [ + SessionMessage.AssistantText.make({ + type: "text", + text: "Checking.", + state: { phase: "commentary" }, + }), + ], + time: { created, completed: created }, + }), + ], + Model.Ref.make({ id: Model.ID.make("same"), providerID: Provider.ID.make("provider") }), + ) + expect(messages[0]?.content).toEqual([ { type: "text",