From 1f0e37be5bfb3ffaff2f45ed007d90b2f0918ed6 Mon Sep 17 00:00:00 2001 From: dev-driver Date: Sat, 7 Nov 2026 12:44:46 +0000 Subject: [PATCH] fix(cli): rig acp serve puts no question to the person after a cancel - After a cancel, the turn's last pass still reads open questions but shows none of them, even one that landed while that read was in flight. Before, the pass the cancel woke could put a new question on screen after the person had pressed stop. - The guide's Limits now say sessions are found among the 2,000 newest rows the caller sent, so a caller that sends a lot can't list or reopen its oldest sessions. - A choice made in the app is recorded as a click in Slack would be. The guide and the code comment no longer say "or the TUI": the TUI records its clicks as the person at the terminal, and this records the caller's address. --- docs/reference/acp-agents.md | 5 +++-- packages/cli/src/acp/agent-server.ts | 4 +++- packages/cli/src/commands/acp.ts | 2 +- packages/cli/test/acp-serve.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/reference/acp-agents.md b/docs/reference/acp-agents.md index 974c41335..38de1d8bc 100644 --- a/docs/reference/acp-agents.md +++ b/docs/reference/acp-agents.md @@ -205,8 +205,7 @@ app that starts the agent from the ACP registry passes no arguments, so that's h - **A question the team asks you** while a turn is open comes to the app as a permission request, with the ask's own choices, when its row is tagged for that chat (`acp-session:`; the task's row tells the team how). An untagged question from a seat the work reached is named in the chat with where to answer it, because it could be another - chat's. Your choice is recorded as your answer, exactly as a click in Slack or the TUI would be, and the seat that - asked gets it. Dismissing it, or cancelling the turn, records nothing: the question stays open in the TUI and Slack. + chat's. Your choice is recorded as your answer, as a click in Slack would be, and the seat that asked gets it. Dismissing it, or cancelling the turn, records nothing: the question stays open in the TUI and Slack. A question that wants an answer in words is shown with where to answer it. - **Sessions are kept in the queue:** the app can list your earlier sessions (`session/list`) and reopen one (`session/load`). Reopening replays each prompt, its work and the reply as they stand now, and a new prompt continues it. @@ -233,3 +232,5 @@ Other apps take the same command and arguments wherever they configure a custom - Questions show only while a turn is open; one asked while no turn is open waits in your usual places (the TUI, Slack). - Prompts are text. Links and embedded text files are passed on as written; images and audio aren't accepted. - Progress is read every 2 seconds while a turn is open (`--poll-seconds`). +- Sessions are found among the 2,000 newest rows you've sent, so a caller that sends a lot, such as a busy seat, can't + list or reopen its oldest sessions. diff --git a/packages/cli/src/acp/agent-server.ts b/packages/cli/src/acp/agent-server.ts index 545b040b7..e5939d5f7 100644 --- a/packages/cli/src/acp/agent-server.ts +++ b/packages/cli/src/acp/agent-server.ts @@ -433,7 +433,9 @@ export function serveAcpAgent(peer: JsonRpcPeer, options: AcpAgentOptions): void say(session.id, state.reply); return { stopReason: "end_turn" }; } - for (const ask of await team.queue.openAsks(team.caller)) { + const asks = await team.queue.openAsks(team.caller); + // After a cancel, even one that landed during that read, no new question goes to the person. + for (const ask of cancelled ? [] : asks) { if (seen.has(ask.qitemId)) continue; // An ask tagged for a chat belongs to that chat alone. An untagged one from a seat this work reached might be // another chat's, so it isn't asked here; it's named, with where to answer it. diff --git a/packages/cli/src/commands/acp.ts b/packages/cli/src/commands/acp.ts index 9d4a68b03..1464d5e92 100644 --- a/packages/cli/src/commands/acp.ts +++ b/packages/cli/src/commands/acp.ts @@ -103,7 +103,7 @@ export function daemonTeamQueue(client: DaemonClient): TeamQueue { })); }, async answer(qitemId, questionId, optionId) { - // The person's answer path, as the TUI records one: the daemon takes the actor from this client's caller. + // The person's answer path, as a click in Slack records one: the daemon takes the actor from this client's caller. const bearer = process.env.OPENRIG_AUTH_BEARER_TOKEN; const response = await client.post>("/api/mission-control/action", { verb: "answer", qitemId, questionId, optionId }, bearer ? { headers: { Authorization: `Bearer ${bearer}` } } : undefined); diff --git a/packages/cli/test/acp-serve.test.ts b/packages/cli/test/acp-serve.test.ts index ec0d1134f..1f5c5a36a 100644 --- a/packages/cli/test/acp-serve.test.ts +++ b/packages/cli/test/acp-serve.test.ts @@ -386,6 +386,28 @@ describe("rig acp serve", () => { expect(db.prepare("SELECT human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-theme'").get()).toEqual({ human_answers: null }); }); + it("after a cancel no new question goes to the app, even one already waiting when the cancel wakes the turn", async () => { + // The poll never fires on its own here, so the only pass after the first is the one the cancel's wake runs. + let scans = 0; + let shown = false; + const hiddenUntilCancel = (q: TeamQueue): TeamQueue => ({ ...q, openAsks: async (person) => { scans += 1; return shown ? q.openAsks(person) : []; } }); + const { client, updates } = connect({ pollMs: 60_000, sleep: (ms) => new Promise((resolve) => { if (ms !== 60_000) setTimeout(resolve, ms); }) }, + "asker5@team", hiddenUntilCancel); + const asked: string[] = []; + client.onRequest("session/request_permission", async (params) => { asked.push((params as any).toolCall.toolCallId); return { outcome: { outcome: "cancelled" } }; }); + const sessionId = await open(client); + const turn = client.request("session/prompt", prompt(sessionId, "Pick a font")); + await until(() => (scans > 0 ? true : undefined), "the first read of open questions"); + await move("asker5@team", "/api/queue/create", { qitemId: "qitem-ask-after-cancel", destinationSession: CALLER, body: "Serif or sans", summary: "Font?", nudge: false, + tags: [`acp-session:${sessionId}`], humanQuestions: [{ id: "q1", question: "Which font?", options: [{ id: "s", label: "Serif" }, { id: "n", label: "Sans" }] }] }); + shown = true; + client.notify("session/cancel", { sessionId }); + expect(await turn).toEqual({ stopReason: "cancelled" }); + expect(asked).toEqual([]); + expect(updates.some((u) => String(u.toolCallId ?? "").startsWith("qitem-ask-after-cancel"))).toBe(false); + expect(db.prepare("SELECT state, human_answers FROM queue_items WHERE qitem_id = 'qitem-ask-after-cancel'").get()).toEqual({ state: "pending", human_answers: null }); + }); + it("when the work closes with a question still on screen, the app is told before the turn ends, and a later reply answers nothing", async () => { const { client, inbound } = connect({}, "asker4@team"); let answerLate!: () => void;