From 77d9d13be145e52a5a2ca2be49b022374922e241 Mon Sep 17 00:00:00 2001 From: Florian Burka Date: Fri, 21 Nov 2025 10:06:44 +0100 Subject: [PATCH] fix: Prevent session from getting stuck on 'No response requested.' messages --- packages/opencode/src/session/processor.ts | 19 +++ .../opencode/test/session/processor.test.ts | 128 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 packages/opencode/test/session/processor.test.ts diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2af8e888eb68..814347ce37c1 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -14,6 +14,16 @@ import { SessionStatus } from "./status" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 + const NO_RESPONSE_PATTERNS = new Set([ + "No response requested.", + "No response requested", + "No response needed.", + "No response needed", + "No response required.", + "No response required", + "No response.", + "No response", + ]) const log = Log.create({ service: "session.processor" }) export type Info = Awaited> @@ -313,6 +323,15 @@ export namespace SessionProcessor { end: Date.now(), } if (value.providerMetadata) currentText.metadata = value.providerMetadata + + // Treat no-response messages as if reasoning ended + if (NO_RESPONSE_PATTERNS.has(currentText.text)) { + log.info("no-response detected, ending processing") + input.assistantMessage.time.completed = Date.now() + await Session.updateMessage(input.assistantMessage) + return + } + await Session.updatePart(currentText) } currentText = undefined diff --git a/packages/opencode/test/session/processor.test.ts b/packages/opencode/test/session/processor.test.ts new file mode 100644 index 000000000000..0acf34be2da3 --- /dev/null +++ b/packages/opencode/test/session/processor.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test" +import { SessionProcessor } from "../../src/session/processor" +import { Session } from "../../src/session" +import { Log } from "../../src/util/log" +import { Instance } from "../../src/project/instance" +import { Storage } from "../../src/storage/storage" +import { Identifier } from "../../src/id/id" +import path from "path" +import { MessageV2 } from "../../src/session/message-v2" + +const projectRoot = path.join(__dirname, "../..") +Log.init({ print: false }) + +describe("SessionProcessor", () => { + describe("no-response handling", () => { + test("should end processing early for no-response messages", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const assistantMessage = MessageV2.Assistant.parse({ + id: Identifier.ascending("message"), + role: "assistant", + sessionID: session.id, + time: { created: Date.now() }, + parentID: session.id, + modelID: "test", + providerID: "test", + mode: "test", + path: { cwd: projectRoot, root: projectRoot }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + await Storage.write( + ["session", Instance.project.id, session.id, "message", assistantMessage.id], + assistantMessage, + ) + + const processor = SessionProcessor.create({ + assistantMessage, + sessionID: session.id, + providerID: "test", + model: { id: "test", provider: "test" } as any, + abort: new AbortController().signal, + }) + + const mockStream = () => + ({ + fullStream: (async function* () { + yield { type: "text-start" } + yield { type: "text-delta", text: "No response requested." } + yield { type: "text-end" } + yield { type: "finish" } + })(), + }) as any + + await processor.process(mockStream) + + const messages = await Session.messages({ sessionID: session.id }) + const assistantMsg = messages.find((m) => m.info.role === "assistant") + expect(assistantMsg?.parts).toHaveLength(1) + expect(assistantMsg?.parts[0]?.type).toBe("text") + if (assistantMsg?.parts[0]?.type === "text") { + expect(assistantMsg.parts[0].text).toBe("No response requested.") + } + + await Session.remove(session.id) + }, + }) + }) + + test("should process normal messages normally", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const session = await Session.create({}) + const assistantMessage = MessageV2.Assistant.parse({ + id: Identifier.ascending("message"), + role: "assistant", + sessionID: session.id, + time: { created: Date.now() }, + parentID: session.id, + modelID: "test", + providerID: "test", + mode: "test", + path: { cwd: projectRoot, root: projectRoot }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + await Storage.write( + ["session", Instance.project.id, session.id, "message", assistantMessage.id], + assistantMessage, + ) + + const processor = SessionProcessor.create({ + assistantMessage, + sessionID: session.id, + providerID: "test", + model: { id: "test", provider: "test" } as any, + abort: new AbortController().signal, + }) + + const mockStream = () => + ({ + fullStream: (async function* () { + yield { type: "text-start" } + yield { type: "text-delta", text: "Here is a helpful response." } + yield { type: "text-end" } + yield { type: "finish" } + })(), + }) as any + + await processor.process(mockStream) + + const messages = await Session.messages({ sessionID: session.id }) + const assistantMsg = messages.find((m) => m.info.role === "assistant") + expect(assistantMsg?.parts).toHaveLength(1) + expect(assistantMsg?.parts[0]?.type).toBe("text") + if (assistantMsg?.parts[0]?.type === "text") { + expect(assistantMsg.parts[0].text).toBe("Here is a helpful response.") + } + + await Session.remove(session.id) + }, + }) + }) + }) +})