fix(ai): preserve text and reasoning after the finish reason (#58851)

This commit is contained in:
Aiden Cline
2026-10-24 22:54:39 -05:00
committed by GitHub
Unverified
parent e0b6039e1f
commit ead522a7df
4 changed files with 56 additions and 20 deletions
+9 -15
View File
@@ -1050,21 +1050,15 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
let lifecycle = state.lifecycle
const reasoning = reasoningDelta(delta, state.reasoningField)
const hasLateContent =
Boolean(delta?.content) ||
Boolean(delta?.refusal) ||
reasoning !== undefined ||
(Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
if (
state.finishReason !== undefined &&
toolDeltas.some((tool) => Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
if (state.finishReason !== undefined) {
if (hasLateContent)
return yield* ProviderShared.eventError(
ADAPTER,
"OpenAI Chat received content after the finish reason",
ProviderShared.encodeJson(event),
)
return [{ ...state, usage }, events] as const
}
)
return yield* ProviderShared.eventError(
ADAPTER,
"OpenAI Chat received content after the finish reason",
ProviderShared.encodeJson(event),
)
const reasoningField = state.reasoningField ?? reasoning?.field
const reasoningTextObserved = state.reasoningTextObserved || reasoning !== undefined
@@ -1175,7 +1169,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
pendingTools,
toolCallEvents: finished?.events ?? state.toolCallEvents,
usage,
finishReason,
finishReason: state.finishReason ?? finishReason,
lifecycle,
reasoningField,
reasoningTextObserved,
@@ -1753,15 +1753,15 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves metadata-only reasoning when the stream ends", () =>
it.effect("preserves metadata-only reasoning arriving after the finish reason", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ choices: [{ delta: { reasoning_details: details } }] },
{ choices: [{ delta: {}, finish_reason: "stop" }] },
{ choices: [{ delta: { reasoning_details: details } }] },
),
),
),
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { HttpClientRequest } from "effect/http"
import { LLM, LLMRequest, Media, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/effect.js"
import { LLM, LLMEvent, LLMRequest, Media, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src/effect.js"
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat.js"
@@ -651,7 +651,49 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("rejects content after a terminal chunk", () =>
it.effect("preserves late text and reasoning for replay without changing the finished tool call", () =>
Effect.gen(function* () {
const details = [{ type: "reasoning.encrypted", data: "opaque", id: "late-reasoning" }]
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
deltaChunk({ reasoning_content: "First" }),
deltaChunk({ content: "Hello" }),
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{}" } }] }),
deltaChunk({}, "tool_calls"),
deltaChunk({ content: " world" }),
deltaChunk({ refusal: " (refusal)" }),
deltaChunk({ reasoning_content: " then" }),
deltaChunk({ reasoning: " more" }),
deltaChunk({ reasoning_text: " thoughts", reasoning_details: details }, "stop"),
usageChunk({ prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 }),
),
),
),
)
expect(response.text).toBe("Hello world (refusal)")
expect(response.reasoning).toBe("First then more thoughts")
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_calls" })
expect(response.usage).toMatchObject({ inputTokens: 11, outputTokens: 7 })
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: {} }])
expect(response.toolCalls).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
response.events.findIndex(LLMEvent.is.toolCall),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.messages[0]).toMatchObject({
role: "assistant",
content: "Hello world (refusal)",
reasoning_content: "First then more thoughts",
reasoning_details: details,
})
}),
)
it.effect("rejects tool changes after a terminal chunk", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { ToolDefinition } from "@opencode/ai"
import { ToolDefinition } from "@opencode/ai/effect"
import { OpenAIChat } from "@opencode/ai/protocols"
import { Agent } from "@opencode/schema/agent"
import { Money } from "@opencode/schema/money"