diff --git a/packages/opencode-router/src/bridge.ts b/packages/opencode-router/src/bridge.ts index a751e3b0..42ab87b1 100644 --- a/packages/opencode-router/src/bridge.ts +++ b/packages/opencode-router/src/bridge.ts @@ -1701,38 +1701,70 @@ export async function startBridge(config: Config, logger: Logger, reporter?: Bri }, "prompt start", ); - const response = await getClient(boundDirectory).session.prompt({ - sessionID, - parts: [{ type: "text", text: promptText }], - ...(effectiveModel ? { model: effectiveModel } : {}), - ...(messagingAgent.selectedAgent ? { agent: messagingAgent.selectedAgent } : {}), - }); - const parts = (response as { parts?: Array<{ type?: string; text?: string; ignored?: boolean }> }).parts ?? []; - const textParts = parts.filter((part) => part.type === "text" && !part.ignored); - logger.debug( - { + + type PromptPart = { type?: string; text?: string; ignored?: boolean }; + + const extractReply = (parts: PromptPart[]) => + parts + .filter((part) => part.type === "text" && !part.ignored) + .map((part) => part.text ?? "") + .join("\n") + .trim(); + + const logPromptResponse = (attempt: "initial" | "retry", parts: PromptPart[]) => { + const textParts = parts.filter((part) => part.type === "text" && !part.ignored); + logger.debug( + { + sessionID, + attempt, + partCount: parts.length, + textCount: textParts.length, + partTypes: parts.map((p) => p.type), + ignoredCount: parts.filter((p) => p.ignored).length, + }, + "prompt response", + ); + }; + + const runPrompt = async (): Promise => { + const response = await getClient(boundDirectory).session.prompt({ sessionID, - partCount: parts.length, - textCount: textParts.length, - partTypes: parts.map((p) => p.type), - ignoredCount: parts.filter((p) => p.ignored).length, - }, - "prompt response", - ); - const reply = parts - .filter((part) => part.type === "text" && !part.ignored) - .map((part) => part.text ?? "") - .join("\n") - .trim(); + parts: [{ type: "text", text: promptText }], + ...(effectiveModel ? { model: effectiveModel } : {}), + ...(messagingAgent.selectedAgent ? { agent: messagingAgent.selectedAgent } : {}), + }); + return (response as { parts?: PromptPart[] }).parts ?? []; + }; + + let parts = await runPrompt(); + logPromptResponse("initial", parts); + let reply = extractReply(parts); + + if (!reply && !parts.some((part) => part.type === "tool")) { + logger.warn({ sessionID }, "prompt returned no visible text; retrying once"); + parts = await runPrompt(); + logPromptResponse("retry", parts); + reply = extractReply(parts); + } if (reply) { logger.debug({ sessionID, replyLength: reply.length }, "reply built"); await sendText(inbound.channel, inbound.identityId, inbound.peerId, reply, { kind: "reply" }); } else { - logger.debug({ sessionID }, "reply empty"); - await sendText(inbound.channel, inbound.identityId, inbound.peerId, "No response generated. Try again.", { - kind: "system", - }); + logger.warn( + { sessionID, partTypes: parts.map((part) => part.type), ignoredCount: parts.filter((part) => part.ignored).length }, + "prompt returned no visible text; clearing session", + ); + store.deleteSession(inbound.channel, inbound.identityId, peerKey); + await sendText( + inbound.channel, + inbound.identityId, + inbound.peerId, + "No visible response was generated. I reset this chat session in case stale state was blocking replies. Send your message again.", + { + kind: "system", + }, + ); } } catch (error) { // Log full error details for debugging diff --git a/packages/opencode-router/test/bridge-e2e.test.js b/packages/opencode-router/test/bridge-e2e.test.js index 45f55817..db808d2c 100644 --- a/packages/opencode-router/test/bridge-e2e.test.js +++ b/packages/opencode-router/test/bridge-e2e.test.js @@ -84,3 +84,81 @@ test("bridge end-to-end: inbound -> prompt -> outbound", async () => { await bridge.stop(); }); + +test("bridge recovers from empty prompt replies by clearing stale session", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencodeRouter-e2e-empty-")); + const dbPath = path.join(dir, "opencode-router.db"); + + const sent = []; + let sessionCounter = 0; + const slackAdapter = { + key: "slack:default", + name: "slack", + identityId: "default", + maxTextLength: 39_000, + async start() {}, + async stop() {}, + async sendText(peerId, text) { + sent.push({ peerId, text }); + }, + }; + + const fakeClient = { + global: { + health: async () => ({ healthy: true, version: "test" }), + }, + session: { + create: async () => { + sessionCounter += 1; + return { id: `session-${sessionCounter}` }; + }, + prompt: async ({ sessionID }) => { + if (sessionID === "session-1") { + return { parts: [] }; + } + return { parts: [{ type: "text", text: "fresh reply" }] }; + }, + }, + }; + + const bridge = await startBridge( + { + configPath: path.join(dir, "opencode-router.json"), + configFile: { version: 1 }, + opencodeUrl: "http://127.0.0.1:4096", + opencodeDirectory: dir, + telegramBots: [], + slackApps: [], + dataDir: dir, + dbPath, + logFile: path.join(dir, "opencode-router.log"), + toolUpdatesEnabled: false, + groupsEnabled: false, + permissionMode: "allow", + toolOutputLimit: 1200, + healthPort: undefined, + logLevel: "silent", + }, + createLoggerStub(), + undefined, + { + client: fakeClient, + adapters: new Map([["slack:default", slackAdapter]]), + disableEventStream: true, + disableHealthServer: true, + }, + ); + + await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D123", text: "first", raw: {} }); + await bridge.dispatchInbound({ channel: "slack", identityId: "default", peerId: "D123", text: "second", raw: {} }); + + assert.equal(sessionCounter, 2); + assert.ok( + sent.some((message) => + message.text.includes("No visible response was generated. I reset this chat session in case stale state was blocking replies."), + ), + ); + assert.ok(sent.some((message) => message.text === "fresh reply")); + + await bridge.stop(); +});