fix(messaging): recover from empty router prompt replies (#645)

This commit is contained in:
ben
2026-02-22 12:45:16 -08:00
committed by GitHub
parent d90e6850ef
commit b2d8a2c9fc
2 changed files with 136 additions and 26 deletions

View File

@@ -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<PromptPart[]> => {
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

View File

@@ -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();
});