diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index ffef495485a..dba54c07532 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -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, diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index ab644c17910..6535c71ba2f 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -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 } }] }, ), ), ), diff --git a/packages/ai/test/provider/openai-compatible-chat.test.ts b/packages/ai/test/provider/openai-compatible-chat.test.ts index 9c3d341550c..9b001a0bd90 100644 --- a/packages/ai/test/provider/openai-compatible-chat.test.ts +++ b/packages/ai/test/provider/openai-compatible-chat.test.ts @@ -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( diff --git a/packages/core/test/session-model-request-hooks.test.ts b/packages/core/test/session-model-request-hooks.test.ts index cda56d2b49e..01ff4ea74de 100644 --- a/packages/core/test/session-model-request-hooks.test.ts +++ b/packages/core/test/session-model-request-hooks.test.ts @@ -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"