diff --git a/server/index.test.ts b/server/index.test.ts index 7274f70cb9..4abc97b2a3 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -74,6 +74,16 @@ beforeAll(async () => { createdAt: 1, dm: true, }, + { + id: "test-cancel-room", + threadId: "test-cancel-room-thread", + name: "Cancel room", + memberIds: ["test-bot-a"], + defaultResponder: { kind: "member", botId: "test-bot-a" }, + bulletin: "", + unread: false, + createdAt: 4, + }, { id: "test-pinned-room", threadId: "test-pinned-room-thread", @@ -88,6 +98,33 @@ beforeAll(async () => { ]), ); + // A room holding an approval nobody has answered yet, so "Cancel turn" + // has something open to close. + writeFileSync( + join(home, ".openmausbot", "messages-test-cancel-room-thread.json"), + JSON.stringify({ + activeLeafId: "cancel-card", + messages: [ + { + id: "cancel-card", + at: 4, + parentId: null, + role: "bot", + kind: "options", + card: { + title: "Approval needed", + subtitle: "rm -rf /tmp/scratch", + options: ["Allow", "Deny"], + requestId: "cancel-request", + tool: "Bash", + allowKey: "Bash:rm", + }, + from: { botId: "test-bot-a", name: "Test bot A", color: "purple" }, + }, + ], + }), + ); + boxStub = createServer(async (req, res) => { if (req.url?.startsWith("/api/v3.1/tool_router/session")) { if (req.headers["x-api-key"] !== "ak_good") { @@ -527,6 +564,21 @@ describe("harness HTTP API", () => { expect(reread.messages.at(-1).tool.name).toContain("request is no longer open"); }); + it("closes the approvals a cancelled turn can no longer answer", async () => { + // "Cancel turn" is a button ON the approval card, and a pending approval + // owns the composer. Stopping the turn without closing its card leaves the + // room blocked by a question whose asker is already gone. + const stopped = await api("POST", "/api/groups/test-cancel-room/interrupt"); + expect(stopped.status).toBe(200); + + const room = (await api("GET", "/api/bots")).body.groups.find( + (group: { id: string }) => group.id === "test-cancel-room", + ); + const card = room.messages.find((message: { id: string }) => message.id === "cancel-card").card; + expect(card.dismissed).toBe(true); + expect(card.answered).toBe("unavailable"); + }); + it("rejects an empty message and explains an unavailable provider", async () => { const { body } = await api("GET", "/api/bots"); const bot = body.bots[0]; diff --git a/server/index.ts b/server/index.ts index 4b94dfd351..64eccebf5b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -429,6 +429,19 @@ async function answerRequest( return outcome; } +/** Close every approval still open on a thread. Interrupting a turn kills the + * process that raised its questions, so those cards can never be answered — + * and a pending approval owns the composer, so one left open blocks the + * conversation behind a question with nobody left to hear the answer. */ +function closeOpenApprovals(threadId: string): void { + for (const message of store.messagesFor(threadId)) { + const card = message.card; + if (!card?.requestId || card.answered || card.dismissed) continue; + store.patchMessage(threadId, message.id, { card: { ...card, answered: "unavailable", dismissed: true } }); + askMessageByRequest.delete(`${threadId}:${card.requestId}`); + } +} + function requestBehavior(value: unknown): "allow" | "deny" | "answer" | null { return value === "allow" || value === "deny" || value === "answer" ? value : null; } @@ -2768,6 +2781,7 @@ const server = createServer(async (req, res) => { const busy = group.busyBotId ? store.bot(group.busyBotId) : undefined; const instance = busy ? registry.get(busy.modelSelection.instanceId) : undefined; await instance?.adapter.interruptTurn(group.threadId).catch(() => {}); + closeOpenApprovals(group.threadId); return json(res, 200, { ok: true }); } @@ -3127,8 +3141,12 @@ const server = createServer(async (req, res) => { // a bot busy in a ROOM is running on the room's thread — stopping it // from its own chat must reach that turn, not just the 1:1 thread const busyGroup = store.groups.find((g) => g.busyBotId === bot.id); - if (busyGroup) await instance?.adapter.interruptTurn(busyGroup.threadId).catch(() => {}); + if (busyGroup) { + await instance?.adapter.interruptTurn(busyGroup.threadId).catch(() => {}); + closeOpenApprovals(busyGroup.threadId); + } await instance?.adapter.interruptTurn(bot.threadId); + closeOpenApprovals(bot.threadId); return json(res, 200, { ok: true }); }