diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 233a4aa4d8..7cc9ea2595 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -305,14 +305,7 @@ export const layer = Layer.effect( const llmFailure = failure instanceof LLMError ? failure : undefined if (llmFailure && !publisher.hasProviderError()) { yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) - yield* withPublication( - events.publish(SessionEvent.Step.Failed, { - sessionID: session.id, - timestamp: yield* DateTime.now, - assistantMessageID: yield* publisher.startAssistant(), - error: { type: "unknown", message: llmFailure.reason.message }, - }), - ) + yield* withPublication(publisher.failAssistant(llmFailure.reason.message)) } if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers) const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) @@ -327,6 +320,8 @@ export const layer = Layer.effect( ) { yield* FiberSet.clear(toolFibers) yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted")) + if (publisher.hasAssistantStarted()) + yield* withPublication(publisher.failAssistant("Provider turn interrupted")) } if (settled._tag === "Failure" && !Cause.hasInterrupts(settled.cause)) { const failure = Cause.squash(settled.cause) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 5390a26e3b..526fb8b4c1 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -66,6 +66,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const timestamp = DateTime.now let assistantMessageID: SessionMessage.ID | undefined let providerFailed = false + let assistantSettled = false const startAssistant = Effect.fnUntraced(function* () { if (assistantMessageID !== undefined) return assistantMessageID @@ -156,6 +157,18 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* toolInput.flush() }) + const failAssistant = Effect.fnUntraced(function* (error: string) { + if (assistantSettled) return + yield* flushFragments() + assistantSettled = true + yield* events.publish(SessionEvent.Step.Failed, { + sessionID: input.sessionID, + timestamp: yield* timestamp, + assistantMessageID: yield* startAssistant(), + error: { type: "unknown", message: error }, + }) + }) + const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) { if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`) const assistantMessageID = yield* startAssistant() @@ -375,6 +388,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } case "step-finish": yield* flush() + assistantSettled = true yield* events.publish(SessionEvent.Step.Ended, { sessionID: input.sessionID, timestamp: yield* timestamp, @@ -388,13 +402,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return case "provider-error": providerFailed = true - yield* flush() - yield* events.publish(SessionEvent.Step.Failed, { - sessionID: input.sessionID, - timestamp: yield* timestamp, - assistantMessageID: yield* startAssistant(), - error: { type: "unknown", message: event.message }, - }) + yield* failAssistant(event.message) return } }) @@ -402,6 +410,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) return { publish, flush, + failAssistant, failUnsettledTools, hasAssistantStarted: () => assistantMessageID !== undefined, hasProviderError: () => providerFailed, diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index ae36f205b1..0a3d030727 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -70,24 +70,34 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid const assistant = (message: SessionMessage.Assistant, model: Model) => { const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) - const content = message.content.flatMap((item): ContentPart[] => { - if (item.type === "text") return [{ type: "text", text: item.text }] - if (item.type === "reasoning") - return sameModel - ? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }] - : item.text.length > 0 - ? [{ type: "text", text: item.text }] - : [] - const call = toolCall(item, sameModel ? item.provider?.metadata : undefined) - const result = toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined) - return item.provider?.executed === true && result ? [call, result] : [call] - }) + const content = message.content + .flatMap((item): ContentPart[] => { + if (item.type === "text") return [{ type: "text", text: item.text }] + if (item.type === "reasoning") + return sameModel + ? [{ type: "reasoning", text: item.text, providerMetadata: item.providerMetadata }] + : item.text.trim().length > 0 + ? [{ type: "text", text: item.text }] + : [] + const call = toolCall(item, sameModel ? item.provider?.metadata : undefined) + const result = toolResult( + item, + sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined, + ) + return item.provider?.executed === true && result ? [call, result] : [call] + }) + .filter(Message.isMeaningfulAssistantPart) const results = message.content .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) .map((item) => toolResult(item, sameModel ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined)) .filter((message) => message !== undefined) .map(Message.tool) - return [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata }), ...results] + return [ + ...(content.length > 0 + ? [Message.make({ id: message.id, role: "assistant", content, metadata: message.metadata })] + : []), + ...results, + ] } function toLLMMessage(message: SessionMessage.Message, model: Model): Message[] { diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 708fd9e7f8..bc26b86744 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -14,6 +14,36 @@ const id = (value: string) => SessionMessage.ID.make(`msg_${value}`) const model = Model.make({ id: "model", provider: "provider", route: OpenAIChat.route }) describe("toLLMMessages", () => { + test("omits empty assistant turns while preserving meaningful assistant content", () => { + const assistant = (value: string, content: SessionMessage.Assistant["content"]) => + new SessionMessage.Assistant({ + id: id(value), + type: "assistant", + agent: "build", + model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, + content, + time: { created, completed: created }, + }) + const messages = toLLMMessages( + [ + assistant("empty", []), + assistant("empty-text", [new SessionMessage.AssistantText({ type: "text", id: "text-empty", text: "" })]), + assistant("text", [new SessionMessage.AssistantText({ type: "text", id: "text", text: "Partial" })]), + assistant("reasoning", [ + new SessionMessage.AssistantReasoning({ + type: "reasoning", + id: "reasoning", + text: "", + providerMetadata: { anthropic: { signature: "sig_1" } }, + }), + ]), + ], + model, + ) + + expect(messages.map((message) => message.id)).toEqual([id("text"), id("reasoning")]) + }) + test("maps every top-level V2 Session message type", () => { const file = new FileAttachment({ uri: "data:image/png;base64,aGVsbG8=", mime: "image/png", name: "hello.png" }) const messages = toLLMMessages( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6ff969fb62..8f72c047d1 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -547,6 +547,8 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "user", text: prompt }, { type: "assistant", + finish: "error", + error: { type: "unknown", message: "Provider turn interrupted" }, content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 5b5bc5ab2d..1ae2af4b3c 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -16,6 +16,7 @@ import { HttpOptions, LLMRequest, LLMResponse, + Message, Model, ModelLimits, LLMError as LLMErrorClass, @@ -172,6 +173,18 @@ const resolveRequestOptions = (request: LLMRequest) => http: mergeHttpOptions(request.model.route.defaults.http, request.http), }) +const omitEmptyAssistantMessages = (request: LLMRequest) => { + const messages = request.messages.flatMap((message) => { + if (message.role !== "assistant") return [message] + const content = message.content.filter(Message.isMeaningfulAssistantPart) + if (content.length === 0) return [] + if (content.length === message.content.length) return [message] + return [Message.make({ ...message, content })] + }) + if (messages.length === request.messages.length) return request + return LLMRequest.update(request, { messages }) +} + export interface MakeInput { /** Route id used in diagnostics and prepared request metadata. */ readonly id: string @@ -335,7 +348,7 @@ export function make( // validated provider body plus transport-private prepared data, but does not // execute transport. const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { - const resolved = applyCachePolicy(resolveRequestOptions(request)) + const resolved = applyCachePolicy(omitEmptyAssistantMessages(resolveRequestOptions(request))) const route = resolved.model.route const body = yield* route.body diff --git a/packages/llm/src/schema/messages.ts b/packages/llm/src/schema/messages.ts index b160f2d4a4..98202b8d88 100644 --- a/packages/llm/src/schema/messages.ts +++ b/packages/llm/src/schema/messages.ts @@ -212,6 +212,16 @@ export namespace Message { export const text = (value: string): ContentPart => ({ type: "text", text: value }) + export const isMeaningfulAssistantPart = (part: ContentPart) => { + if (part.type === "text") return part.text.trim().length > 0 + if (part.type !== "reasoning") return true + return ( + part.text.trim().length > 0 || + part.encrypted !== undefined || + (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0) + ) + } + export const content = (input: ContentInput) => typeof input === "string" ? [text(input)] : Array.isArray(input) ? [...input] : [input] diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 9966b92e3d..8ee1c05bb3 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -92,6 +92,49 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("omits empty assistant turns before protocol serialization", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [Message.user("Before"), Message.assistant(""), Message.user("After")], + }), + ) + + expect(prepared.body.messages).toEqual([ + { role: "user", content: "Before" }, + { role: "user", content: "After" }, + ]) + }), + ) + + it.effect("removes empty assistant parts while preserving tool calls", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { type: "text", text: "" }, + ToolCallPart.make({ id: "call_1", name: "read", input: { path: "README.md" } }), + ]), + ], + }), + ) + + expect(prepared.body.messages).toEqual([ + { + role: "assistant", + content: "", + reasoning_content: undefined, + tool_calls: [ + { id: "call_1", type: "function", function: { name: "read", arguments: '{"path":"README.md"}' } }, + ], + }, + ]) + }), + ) + it.effect("maps OpenAI provider options to Chat options", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index e459380faa..e2be4771fc 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -129,62 +129,25 @@ function normalizeMessages( } }) - // Anthropic rejects messages with empty content - filter out empty string messages - // and remove empty text/reasoning parts from array content - if (model.api.npm === "@ai-sdk/anthropic") { - msgs = msgs - .map((msg) => { - if (typeof msg.content === "string") { - if (msg.content === "") return undefined - return msg - } - if (!Array.isArray(msg.content)) return msg - const filtered = msg.content.filter((part) => { - if (part.type === "text") { - return part.text !== "" - } - if (part.type === "reasoning") { - return ( - part.text.trim().length > 0 || - part.providerOptions?.anthropic?.signature != null || - part.providerOptions?.anthropic?.redactedData != null - ) - } - return true - }) - if (filtered.length === 0) return undefined - return { ...msg, content: filtered } + // Empty assistant turns carry no history and are rejected by several provider APIs. + msgs = msgs + .map((msg) => { + if (msg.role !== "assistant") return msg + if (typeof msg.content === "string") return msg.content.trim().length > 0 ? msg : undefined + if (!Array.isArray(msg.content)) return msg + const content = msg.content.filter((part) => { + if (part.type === "text") return part.text.trim().length > 0 + if (part.type !== "reasoning") return true + return ( + part.text.trim().length > 0 || + (part.providerOptions !== undefined && Object.keys(part.providerOptions).length > 0) + ) }) - .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") - } - - // Bedrock specific transforms - if (model.api.npm === "@ai-sdk/amazon-bedrock") { - msgs = msgs - .map((msg) => { - if (typeof msg.content === "string") { - if (msg.content === "") return undefined - return msg - } - if (!Array.isArray(msg.content)) return msg - const filtered = msg.content.filter((part) => { - if (part.type === "text") { - return part.text !== "" - } - if (part.type === "reasoning") { - return ( - part.text.trim().length > 0 || - part.providerOptions?.bedrock?.signature != null || - part.providerOptions?.bedrock?.redactedData != null - ) - } - return true - }) - if (filtered.length === 0) return undefined - return { ...msg, content: filtered } - }) - .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") - } + if (content.length === 0) return undefined + if (content.length === msg.content.length) return msg + return { ...msg, content } + }) + .filter((msg): msg is ModelMessage => msg !== undefined) if (model.api.id.includes("claude")) { const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_") diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 1590e08903..ef244bee17 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -255,15 +255,18 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (msg.info.role === "assistant") { const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` const media: Array<{ mime: string; url: string; filename?: string }> = [] + const hasSignedReasoning = msg.parts.some((part) => { + if (part.type !== "reasoning") return false + return part.metadata?.anthropic?.signature != null + }) - if ( - msg.info.error && - !( - AbortedError.isInstance(msg.info.error) && - msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) - ) { - continue + if (msg.info.error) { + const hasOutput = msg.parts.some((part) => { + if (part.type === "text") return part.text.trim().length > 0 + if (part.type === "reasoning") return part.text.trim().length > 0 || hasSignedReasoning + return part.type === "tool" + }) + if (!(AbortedError.isInstance(msg.info.error) && hasOutput)) continue } const assistantMessage: UIMessage = { id: msg.info.id, @@ -281,10 +284,6 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( // here is the only safe replay point we have. // Use a single space so the separator survives replay without changing // the neighboring signed reasoning blocks. - const hasSignedReasoning = msg.parts.some((part) => { - if (part.type !== "reasoning") return false - return part.metadata?.anthropic?.signature != null - }) for (const part of msg.parts) { if (part.type === "text") { const text = part.text === "" && hasSignedReasoning ? " " : part.text diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2554315908..33dede1816 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -950,6 +950,7 @@ export const layer = Layer.effect( } } ctx.assistantMessage.error = error + ctx.assistantMessage.finish = "error" yield* events.publish(Session.Event.Error, { sessionID: ctx.assistantMessage.sessionID, error: ctx.assistantMessage.error, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index dad796c998..8c5a118567 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1259,6 +1259,7 @@ export const layer = Layer.effect( providerID: msg.providerID, aborted: true, }) + msg.finish = "error" msg.time.completed = Date.now() yield* sessions.updateMessage(msg) }) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 5fa530155d..160631b22c 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2018,7 +2018,7 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => expect(result[1].content[0]).toEqual({ type: "text", text: "Answer" }) }) - test("does not filter for non-anthropic providers", () => { + test("filters empty assistant content for non-anthropic providers", () => { const openaiModel = { ...anthropicModel, providerID: "openai", @@ -2039,9 +2039,32 @@ describe("ProviderTransform.message - anthropic empty content filtering", () => const result = ProviderTransform.message(msgs, openaiModel, {}) - expect(result).toHaveLength(2) - expect(result[0].content).toBe("") - expect(result[1].content).toHaveLength(1) + expect(result).toStrictEqual([]) + }) + + test("preserves metadata-only reasoning for non-anthropic providers", () => { + const openaiModel = { + ...anthropicModel, + providerID: "openai", + api: { id: "gpt-5", url: "https://api.openai.com", npm: "@ai-sdk/openai" }, + } + const reasoning = { + type: "reasoning", + text: "", + providerOptions: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted" } }, + } as const + + expect( + ProviderTransform.message([{ role: "assistant", content: [reasoning] }], openaiModel, {})[0]?.content, + ).toContainEqual( + expect.objectContaining({ + type: "reasoning", + text: "", + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ reasoningEncryptedContent: "encrypted" }), + }), + }), + ) }) test("leaves valid anthropic assistant tool ordering unchanged", () => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 1de84c9dd9..27dc30df19 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -988,7 +988,7 @@ describe("session.message-v2.toModelMessage", () => { expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) }) - test("includes aborted assistant messages only when they have non-step-start/reasoning content", async () => { + test("includes aborted assistant messages only when they have meaningful output", async () => { const assistantID1 = "m-assistant-1" const assistantID2 = "m-assistant-2" @@ -1038,6 +1038,67 @@ describe("session.message-v2.toModelMessage", () => { { type: "text", text: "partial answer" }, ], }, + { + role: "assistant", + content: [{ type: "reasoning", text: "thinking", providerOptions: undefined }], + }, + ]) + }) + + test("filters aborted assistant messages with only empty text", async () => { + const assistantID = "m-assistant" + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo( + assistantID, + "m-parent", + new SessionV1.AbortedError({ message: "aborted" }).toObject() as SessionV1.Assistant["error"], + ), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "text", + text: "", + }, + ] as SessionV1.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([]) + }) + + test("preserves aborted assistant messages with signed reasoning", async () => { + const assistantID = "m-assistant" + const input: SessionV1.WithParts[] = [ + { + info: assistantInfo( + assistantID, + "m-parent", + new SessionV1.AbortedError({ message: "aborted" }).toObject() as SessionV1.Assistant["error"], + ), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "reasoning", + text: "", + time: { start: 0 }, + metadata: { anthropic: { signature: "sig_1" } }, + }, + ] as SessionV1.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "", + providerOptions: { anthropic: { signature: "sig_1" } }, + }, + ], + }, ]) }) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index c8f40d0de1..4581d291d8 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -899,9 +899,13 @@ it.live("session.processor effect tests record aborted errors and idle state", ( expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true) } expect(handle.message.error?.name).toBe("MessageAbortedError") + expect(handle.message.finish).toBe("error") + expect(handle.message.time.completed).toBeNumber() expect(stored.info.role).toBe("assistant") if (stored.info.role === "assistant") { expect(stored.info.error?.name).toBe("MessageAbortedError") + expect(stored.info.finish).toBe("error") + expect(stored.info.time.completed).toBeNumber() } expect(state).toMatchObject({ type: "idle" }) expect(errs).toContain("MessageAbortedError") @@ -957,9 +961,13 @@ it.live("session.processor effect tests mark interruptions aborted without manua expect(Exit.isFailure(exit)).toBe(true) expect(handle.message.error?.name).toBe("MessageAbortedError") + expect(handle.message.finish).toBe("error") + expect(handle.message.time.completed).toBeNumber() expect(stored.info.role).toBe("assistant") if (stored.info.role === "assistant") { expect(stored.info.error?.name).toBe("MessageAbortedError") + expect(stored.info.finish).toBe("error") + expect(stored.info.time.completed).toBeNumber() } expect(state).toMatchObject({ type: "idle" }) }), diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5cd97f78e8..7e7e63cfdc 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1082,7 +1082,7 @@ raceNoLLMServer.instance( expect(firstInterrupted?.info.role).toBe("assistant") expect(firstInterrupted?.parts).toHaveLength(0) if (firstInterrupted?.info.role === "assistant") { - expect(firstInterrupted.info.finish).toBeUndefined() + expect(firstInterrupted.info.finish).toBe("error") expect(firstInterrupted.info.time.completed).toBeNumber() expect(firstInterrupted.info.error?.name).toBe("MessageAbortedError") }