From e1ea1a89d1b55eb2a122c8f4c4d7631613086e4a Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Mon, 15 Jun 2026 15:27:25 +0100 Subject: [PATCH] fix(agent): stop auto-answering questions before desktop connects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AskUserQuestion tool calls were intermittently being auto-answered (surfacing as "Question answered"/"Answer received") without any user interaction. In createCloudClient.requestPermission, questions only relayed to the desktop when session.hasDesktopConnected was already true. That flag starts false for cloud-initialized sessions and only flips true once the desktop's SSE stream attaches. If the agent fired a question during that startup window, it fell through to the auto-approve path and silently selected the first option — the race that made it intermittent. Treat questions like plan approvals: always relay and wait. The permission_request is buffered by broadcastEvent and replayed when the desktop connects (and persisted to the log), so an early question is no longer auto-answered. Generated-By: PostHog Code Task-Id: 4c98167b-4c73-424e-9f2c-0e1200d41041 --- packages/agent/src/server/agent-server.ts | 17 ++++--- .../agent/src/server/question-relay.test.ts | 48 +++++++++++++++++-- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 2ca34b221..2a296839b 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -2146,19 +2146,24 @@ ${signedCommitInstructions} // Relay permission requests to the desktop app when: // - Plan approvals: always relay because they gate autonomy changes // that require human confirmation (buffered until desktop connects) - // - Questions: relay when desktop is connected - // - Edit/bash in "default" mode: relay for manual approval + // - Questions: always relay because they explicitly solicit a human + // answer. Like plan approvals, the request is buffered and replayed + // when the desktop connects, so a question fired before the SSE + // stream attaches is never silently auto-answered. + // - Edit/bash in "default" mode: relay for manual approval, but only + // when a desktop is connected — otherwise auto-approve. // Other modes auto-approve. No client connected → auto-approve - // (except plan approvals, which wait for a desktop). + // (except plan approvals and questions, which wait for a desktop). { const isQuestion = codeToolKind === "question"; const sessionPermissionMode = this.getSessionPermissionMode(); - const needsDesktopApproval = - isQuestion || - this.shouldRelayPermissionToClient(sessionPermissionMode); + const needsDesktopApproval = this.shouldRelayPermissionToClient( + sessionPermissionMode, + ); if ( isPlanApproval || + isQuestion || (needsDesktopApproval && this.session?.hasDesktopConnected) ) { this.logger.debug("Relaying permission request", { diff --git a/packages/agent/src/server/question-relay.test.ts b/packages/agent/src/server/question-relay.test.ts index 073328a40..7ddc8924c 100644 --- a/packages/agent/src/server/question-relay.test.ts +++ b/packages/agent/src/server/question-relay.test.ts @@ -279,15 +279,53 @@ describe("Question relay", () => { delete process.env.POSTHOG_CODE_INTERACTION_ORIGIN; }); - it("auto-approves question tools (no Slack relay)", async () => { - const client = server.createCloudClient(TEST_PAYLOAD); + it("relays question tools and waits for an answer even before a desktop connects", async () => { + // Regression: questions used to auto-approve (silently picking the + // first option) when the desktop SSE stream had not yet attached, + // which surfaced as questions being auto-dismissed/answered. They must + // instead relay and wait, exactly like plan approvals. + const appendRawLine = vi.fn(); + const srv = server as TestableAgentServer & { + resolvePermission: (requestId: string, optionId: string) => boolean; + session: { + payload: typeof TEST_PAYLOAD; + sseController: null; + hasDesktopConnected: boolean; + logWriter: { appendRawLine: typeof appendRawLine }; + }; + }; + srv.session = { + payload: TEST_PAYLOAD, + sseController: null, + hasDesktopConnected: false, + logWriter: { appendRawLine }, + }; - const result = await client.requestPermission({ + const client = srv.createCloudClient(TEST_PAYLOAD); + const promise = client.requestPermission({ options: ALLOW_OPTIONS, - toolCall: { _meta: QUESTION_META }, + toolCall: { toolCallId: "q-1", _meta: QUESTION_META }, }); - expect(result.outcome.outcome).toBe("selected"); + // It must not resolve on its own — no auto-answer. + let settled = false; + void promise.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + // The request was relayed/persisted with a requestId we can answer. + const request = appendRawLine.mock.calls + .map(([, line]) => JSON.parse(line)) + .find((n) => n?.method === "_posthog/permission_request"); + expect(request).toBeTruthy(); + const requestId = request.params.requestId as string; + + expect(srv.resolvePermission(requestId, "allow")).toBe(true); + await expect(promise).resolves.toMatchObject({ + outcome: { outcome: "selected", optionId: "allow" }, + }); }); it("keeps auto-approving permissions after SSE send failures", async () => {