fix(ai): preserve thought signatures on visible text parts (#43984)

This commit is contained in:
Aiden Cline
2026-08-21 15:28:43 -05:00
committed by GitHub
parent e945ddf80e
commit 87ef814190
5 changed files with 134 additions and 19 deletions
+28 -13
View File
@@ -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<LLMEvent> => {
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,
+8 -2
View File
@@ -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
}
+55
View File
@@ -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(
@@ -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
@@ -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",