From 1eace13863cf3837972f254f82c38a5febcaddf8 Mon Sep 17 00:00:00 2001 From: xhyyxl <1215937291@qq.com> Date: Sun, 19 Jul 2026 16:10:35 +0800 Subject: [PATCH] fix(opencode): surface empty model responses --- packages/core/src/v1/session.ts | 1 + packages/opencode/src/session/processor.ts | 13 ++++++++ packages/opencode/test/session/prompt.test.ts | 32 +++++++++++++++++++ packages/schema/src/v1/session.ts | 2 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 11 +++++++ 5 files changed, 59 insertions(+) diff --git a/packages/core/src/v1/session.ts b/packages/core/src/v1/session.ts index b4ce31e5f09a..275acf69ed9a 100644 --- a/packages/core/src/v1/session.ts +++ b/packages/core/src/v1/session.ts @@ -66,3 +66,4 @@ export const ContextOverflowError = NamedError.create("ContextOverflowError", { responseBody: Schema.optional(Schema.String), }) export const ContentFilterError = NamedError.create("ContentFilterError", { message: Schema.String }) +export const EmptyResponseError = NamedError.create("EmptyResponseError", { message: Schema.String }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 20aa8a8404d8..15e0d6c0166e 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -72,6 +72,7 @@ interface ProcessorContext extends Input { needsCompaction: boolean currentText: SessionV1.TextPart | undefined reasoningMap: Record + hasOutput: boolean } type StreamEvent = LLMEvent @@ -111,6 +112,7 @@ const layer = Layer.effect( needsCompaction: false, currentText: undefined, reasoningMap: {}, + hasOutput: false, } let aborted = false @@ -333,6 +335,7 @@ const layer = Layer.effect( throw new Error(`Tool call not allowed while generating summary: ${value.name}`) } yield* ensureToolCall(value) + ctx.hasOutput = true const input = isRecord(value.input) ? value.input : { value: value.input } yield* updateToolCall(value.id, (match) => ({ ...match, @@ -441,6 +444,12 @@ const layer = Layer.effect( metadata: value.providerMetadata, }) ctx.assistantMessage.finish = value.reason + const emptyResponse = value.reason === "stop" && !ctx.hasOutput + if (emptyResponse) { + ctx.assistantMessage.error = new SessionV1.EmptyResponseError({ + message: "The model completed without producing text or a tool call", + }).toObject() + } ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ @@ -454,6 +463,9 @@ const layer = Layer.effect( cost: usage.cost, }) yield* session.updateMessage(ctx.assistantMessage) + if (emptyResponse) { + yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error: ctx.assistantMessage.error }) + } if (ctx.snapshot) { const patch = yield* snapshot.patch(ctx.snapshot) if (patch.files.length) { @@ -498,6 +510,7 @@ const layer = Layer.effect( case "text-delta": if (!ctx.currentText) return + if (value.text.length > 0) ctx.hasOutput = true ctx.currentText.text += value.text if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata yield* session.updatePartDelta({ diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf47..f3b7ad538a47 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -634,6 +634,38 @@ it.instance("loop surfaces content-filter finishes as session errors", () => }), ) +it.instance("loop surfaces empty completed responses as session errors", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Empty response" }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.push(reply().usage({ input: 5, output: 1 }).stop()) + + const result = yield* prompt.loop({ sessionID: chat.id }) + const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: result.info.id }) + const expected = { + name: "EmptyResponseError", + data: { message: "The model completed without producing text or a tool call" }, + } satisfies NonNullable + + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.finish).toBe("stop") + expect(result.info.error).toEqual(expected) + } + if (stored.info.role === "assistant") expect(stored.info.error).toEqual(expected) + expect(yield* llm.hits).toHaveLength(1) + }), +) + it.instance("loop stops provider overflow instead of auto-compacting when disabled", () => Effect.gen(function* () { const { llm } = yield* useServerConfig((url) => ({ diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts index 75e9282f117c..57df4743b071 100644 --- a/packages/schema/src/v1/session.ts +++ b/packages/schema/src/v1/session.ts @@ -61,6 +61,7 @@ export const ContextOverflowError = namedError("ContextOverflowError", { export const ContentFilterError = namedError("ContentFilterError", { message: Schema.String, }) +export const EmptyResponseError = namedError("EmptyResponseError", { message: Schema.String }) export class OutputFormatText extends Schema.Class("OutputFormatText")({ type: Schema.Literal("text"), @@ -390,6 +391,7 @@ const AssistantErrorSchema = Schema.Union([ StructuredOutputError.EffectSchema, ContextOverflowError.EffectSchema, ContentFilterError.EffectSchema, + EmptyResponseError.EffectSchema, APIError.EffectSchema, ]).annotate({ discriminator: "name" }) type AssistantError = Schema.Schema.Type diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 42d224780d32..499c8bff2a90 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -314,6 +314,13 @@ export type ContentFilterError = { } } +export type EmptyResponseError = { + name: "EmptyResponseError" + data: { + message: string + } +} + export type ApiError = { name: "APIError" data: { @@ -346,6 +353,7 @@ export type AssistantMessage = { | StructuredOutputError | ContextOverflowError | ContentFilterError + | EmptyResponseError | ApiError parentID: string modelID: string @@ -1222,6 +1230,7 @@ export type GlobalEvent = { | StructuredOutputError | ContextOverflowError | ContentFilterError + | EmptyResponseError | ApiError } } @@ -5357,6 +5366,7 @@ export type SessionError = { | StructuredOutputError | ContextOverflowError | ContentFilterError + | EmptyResponseError | ApiError } } @@ -6682,6 +6692,7 @@ export type EventSessionError = { | StructuredOutputError | ContextOverflowError | ContentFilterError + | EmptyResponseError | ApiError } }