mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(ai): preserve text and reasoning after the finish reason (#58851)
This commit is contained in:
committed by
GitHub
Unverified
parent
e0b6039e1f
commit
ead522a7df
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user