fix(ai): reconcile reasoning finals that arrive without deltas (#44595)

This commit is contained in:
Aiden Cline
2026-08-24 00:29:23 -05:00
committed by GitHub
parent 297f5298cc
commit e63aba48bc
6 changed files with 176 additions and 22 deletions
+36 -5
View File
@@ -353,6 +353,10 @@ interface ReasoningStreamItem {
// strings, but typing the map as `Record<number, ...>` documents intent
// and matches the wire field.
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
// Summary indexes that received at least one streamed delta. The `:0` block
// is started eagerly when the item opens, so block existence cannot tell
// whether a `.done` final would duplicate streamed text.
readonly deltaIndexes: ReadonlySet<number>
}
// =============================================================================
@@ -805,19 +809,33 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
}
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
if (!event.delta || !state.reasoningItems[itemID]) return [state, NO_EVENTS]
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
const index = event.summary_index ?? 0
const events: LLMEvent[] = []
const id = `${itemID}:${event.summary_index ?? 0}`
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...state.reasoningItems,
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
},
},
events,
]
}
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
// Some compatible gateways emit a reasoning final without streaming any
// deltas, mirroring `response.output_text.done`. Reconcile the complete text
// as a single delta unless that summary index already streamed one.
export const onReasoningDone = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!item || typeof event.text !== "string") return [state, NO_EVENTS]
const index = event.summary_index ?? 0
if (item.deltaIndexes.has(index)) return [state, NO_EVENTS]
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
@@ -855,7 +873,11 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
[item.id]: {
encryptedContent: item.encrypted_content,
summaryParts: { 0: "active" },
deltaIndexes: new Set(),
},
},
},
events,
@@ -1161,6 +1183,15 @@ export const step = (state: ParserState, event: Event) => {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (
event.type === "response.reasoning.done" ||
event.type === "response.reasoning_summary_text.done" ||
event.type === "response.reasoning_summary.done" ||
event.type === "response.reasoning_text.done"
) {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event, event.item_id))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
@@ -166,10 +166,6 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
+2 -10
View File
@@ -1,7 +1,5 @@
import { Effect } from "effect"
import { Protocol } from "../route/protocol.js"
import { OpenResponses } from "./open-responses.js"
import { ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
@@ -27,15 +25,9 @@ const HOSTED_TOOLS = {
},
} as const satisfies ResponsesHostedTools.Definitions
// Grok speaks the standard Responses reasoning dialect (`reasoning_summary_text.*`,
// handled by the baseline); only its hosted tool vocabulary differs.
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
return OpenResponses.step(state, event)
@@ -173,6 +173,37 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
},
// Raw reasoning finals carry no summary index; they reconcile
// into the item's first block.
{ type: "response.reasoning.done", item_id: "rs_raw", text: "Raw chain of thought." },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Raw chain of thought.")
}),
)
it.effect("preserves nullable phases in the forgiving Open Responses baseline", () =>
Effect.gen(function* () {
const model = configure({
@@ -2196,6 +2196,105 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles reasoning summaries that arrive only as finals", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
// No `.delta` events at all: the gateway sends the complete
// summary text in the `.done` final.
{
type: "response.reasoning_summary_text.done",
item_id: "rs_1",
summary_index: 0,
text: "Checked the diff.",
},
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Checked the diff.")
expect(response.events.filter((event) => event.type.startsWith("reasoning-"))).toEqual([
{
type: "reasoning-start",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
},
{ type: "reasoning-delta", id: "rs_1:0", text: "Checked the diff.", providerMetadata: undefined },
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
])
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [response.message],
providerOptions: { store: false, include: ["reasoning.encrypted_content"] },
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "encrypted-state",
},
])
}),
)
it.effect("does not duplicate reasoning finals after streamed deltas", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1", encrypted_content: null } },
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "Streamed" },
// Repeats the complete text, as the spec allows.
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", summary_index: 0, text: "Streamed" },
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Streamed")
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([
{ type: "reasoning-delta", id: "rs_1:0", text: "Streamed", providerMetadata: undefined },
])
}),
)
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
@@ -24,7 +24,7 @@ describe("xAI Responses route", () => {
}),
)
it.effect("parses xAI reasoning text events", () =>
it.effect("parses xAI reasoning summaries", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
@@ -34,8 +34,13 @@ describe("xAI Responses route", () => {
type: "response.output_item.added",
item: { type: "reasoning", id: "reasoning_1" },
},
{ type: "response.reasoning_text.delta", item_id: "reasoning_1", delta: "Considering." },
{ type: "response.reasoning_text.done", item_id: "reasoning_1" },
// Grok streams reasoning with the standard summary event name.
{
type: "response.reasoning_summary_text.delta",
item_id: "reasoning_1",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },