fix(ai): preserve thought signatures on visible text parts (#43984)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user