From d78f4c1aa76b4097a746e1f79f2fa8fabab22fff Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:26:28 +0200 Subject: [PATCH] fix(opencode): surface truncated turns instead of ending the loop --- packages/opencode/src/session/prompt.ts | 21 ++++- packages/opencode/test/lib/llm-server.ts | 8 ++ packages/opencode/test/session/prompt.test.ts | 83 +++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eb116f6b960f..8fa010b4854f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1110,7 +1110,9 @@ const layer = Layer.effect( if ( lastAssistant?.finish && - !["tool-calls"].includes(lastAssistant.finish) && + // A recoverable length finish must reach the continuation decision below; + // otherwise this history reload exits before the extra provider turn can run. + !["tool-calls", "length"].includes(lastAssistant.finish) && !hasToolCalls && lastUser.id < lastAssistant.id ) { @@ -1292,6 +1294,23 @@ const layer = Layer.effect( return "break" as const } + if (handle.message.finish === "length") { + // A length finish truncates provider output. Persisted parts identify + // text or tools worth one continuation; reasoning-only output must + // surface an error instead of ending silently. The previous finish + // bounds this to one attempt without adding loop state. + const current = yield* MessageV2.get({ sessionID, messageID: handle.message.id }).pipe( + Effect.provideService(Database.Service, database), + Effect.orElseSucceed(() => undefined), + ) + const usable = current?.parts.some((part) => part.type === "text" || part.type === "tool") ?? false + if (usable && lastAssistant?.finish !== "length") return "continue" as const + + handle.message.error = new SessionV1.OutputLengthError({}).toObject() + yield* sessions.updateMessage(handle.message) + return "break" as const + } + const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish) if (finished && !handle.message.error) { // Surface any content-filter finish (e.g. Anthropic stop_reason: diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 245acc7280f5..c456df35f5e1 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -493,6 +493,14 @@ export class Reply { return this } + length() { + this.#finish = "length" + this.#hang = false + this.#error = undefined + this.#reset = false + return this + } + contentFilter() { this.#finish = "content_filter" this.#hang = false diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 491ad06aaf47..d1f97af8e523 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -634,6 +634,89 @@ it.instance("loop surfaces content-filter finishes as session errors", () => }), ) +// This verifies end-to-end recovery; the error and one-continuation decisions are pinned by the following cases. +it.instance("loop completes end-to-end recovery after a recoverable length finish", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + yield* llm.push(reply().text("partial").length(), reply().text("complete").stop()) + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(yield* llm.hits).toHaveLength(2) + expect(result.info).toMatchObject({ role: "assistant", finish: "stop" }) + expect(result.parts).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "text", text: "complete" })]), + ) + }), +) + +it.instance("loop surfaces reasoning-only length finish as an error", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + yield* llm.push(reply().reason("unfinished reasoning").length()) + const result = yield* prompt.loop({ sessionID: chat.id }) + const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: result.info.id }) + expect(yield* llm.hits).toHaveLength(1) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info).toMatchObject({ + finish: "length", + error: { name: "MessageOutputLengthError", data: {} }, + }) + expect(stored.info).toMatchObject({ error: result.info.error }) + } + expect(result.parts).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "reasoning", text: "unfinished reasoning" })]), + ) + }), +) + +it.instance("loop bounds continuation after a second length finish", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + yield* llm.push( + reply().text("first partial").length(), + reply().text("second partial").length(), + reply().text("must not run").stop(), + ) + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(yield* llm.hits).toHaveLength(2) + expect(result.info).toMatchObject({ + role: "assistant", + finish: "length", + error: { name: "MessageOutputLengthError", data: {} }, + }) + }), +) + it.instance("loop stops provider overflow instead of auto-compacting when disabled", () => Effect.gen(function* () { const { llm } = yield* useServerConfig((url) => ({