Skip to content

Commit 5699d3e

Browse files
committed
fix(cloud-task): recover pending permission prompts from the run log on reconnect
A cloud task awaiting a permission/plan approval surfaces it as a live SSE permission_request event that the renderer holds only in memory. After a renderer reload or app restart the client reconnects with start=latest and the prompt is gone, so the user can't answer and the run looks stuck. The agent-server already persists its other lifecycle events to the run log (turn_complete, git_checkpoint, ...). Do the same for permissions: write a _posthog/permission_request (with the requestId needed to answer) when a request is raised and a _posthog/permission_resolved when it is answered. On reconnect the client derives still-pending requests from the snapshot it already loads (request entries with no matching resolved entry) and re-surfaces them, recovering the requestId so the existing answer path works unchanged. No backend changes.
1 parent bf90d93 commit 5699d3e

5 files changed

Lines changed: 206 additions & 3 deletions

File tree

apps/code/src/renderer/features/sessions/service/service.test.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,12 @@ vi.mock("@utils/session", async () => {
313313
});
314314

315315
import { toast } from "@renderer/utils/toast";
316-
import { getSessionService, resetSessionService } from "./service";
316+
import type { StoredLogEntry } from "@shared/types/session-events";
317+
import {
318+
derivePendingPermissionRequests,
319+
getSessionService,
320+
resetSessionService,
321+
} from "./service";
317322

318323
// --- Test Fixtures ---
319324

@@ -4669,3 +4674,65 @@ describe("SessionService", () => {
46694674
});
46704675
});
46714676
});
4677+
4678+
describe("derivePendingPermissionRequests", () => {
4679+
const request = (requestId: string, toolCallId: string): StoredLogEntry => ({
4680+
type: "notification",
4681+
notification: {
4682+
method: "_posthog/permission_request",
4683+
params: {
4684+
requestId,
4685+
toolCallId,
4686+
toolCall: { toolCallId, title: "Ready to code?" },
4687+
options: [],
4688+
},
4689+
},
4690+
});
4691+
const resolved = (requestId: string): StoredLogEntry => ({
4692+
type: "notification",
4693+
notification: {
4694+
method: "_posthog/permission_resolved",
4695+
params: { requestId },
4696+
},
4697+
});
4698+
4699+
it("returns only unanswered requests, carrying their requestId", () => {
4700+
const pending = derivePendingPermissionRequests([
4701+
request("r1", "t1"),
4702+
resolved("r1"),
4703+
request("r2", "t2"),
4704+
]);
4705+
4706+
expect(pending.map((p) => p.requestId)).toEqual(["r2"]);
4707+
expect(pending[0].toolCall.toolCallId).toBe("t2");
4708+
});
4709+
4710+
it("ignores unrelated entries and requests without a requestId", () => {
4711+
const pending = derivePendingPermissionRequests([
4712+
{
4713+
type: "notification",
4714+
notification: { method: "_posthog/console", params: {} },
4715+
},
4716+
{
4717+
type: "notification",
4718+
notification: { method: "_posthog/permission_request", params: {} },
4719+
},
4720+
]);
4721+
4722+
expect(pending).toEqual([]);
4723+
});
4724+
4725+
it("drops requests missing a toolCall so they never reach the handler", () => {
4726+
const pending = derivePendingPermissionRequests([
4727+
{
4728+
type: "notification",
4729+
notification: {
4730+
method: "_posthog/permission_request",
4731+
params: { requestId: "r1", options: [] },
4732+
},
4733+
},
4734+
]);
4735+
4736+
expect(pending).toEqual([]);
4737+
});
4738+
});

apps/code/src/renderer/features/sessions/service/service.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,42 @@ function hasSessionPromptEvent(events: AcpMessage[]): boolean {
152152
);
153153
}
154154

155+
type DerivedPermissionRequest = Pick<
156+
CloudTaskPermissionRequestUpdate,
157+
"requestId" | "toolCall" | "options"
158+
>;
159+
160+
export function derivePendingPermissionRequests(
161+
entries: StoredLogEntry[],
162+
): DerivedPermissionRequest[] {
163+
const requests = new Map<string, DerivedPermissionRequest>();
164+
const resolved = new Set<string>();
165+
for (const entry of entries) {
166+
const method = entry.notification?.method;
167+
if (!method) continue;
168+
const params = (entry.notification?.params ?? {}) as {
169+
requestId?: string;
170+
toolCall?: CloudTaskPermissionRequestUpdate["toolCall"];
171+
options?: CloudTaskPermissionRequestUpdate["options"];
172+
};
173+
if (typeof params.requestId !== "string") continue;
174+
if (isNotification(method, POSTHOG_NOTIFICATIONS.PERMISSION_RESOLVED)) {
175+
resolved.add(params.requestId);
176+
} else if (
177+
isNotification(method, POSTHOG_NOTIFICATIONS.PERMISSION_REQUEST) &&
178+
typeof params.toolCall?.toolCallId === "string" &&
179+
Array.isArray(params.options)
180+
) {
181+
requests.set(params.requestId, {
182+
requestId: params.requestId,
183+
toolCall: params.toolCall,
184+
options: params.options,
185+
});
186+
}
187+
}
188+
return [...requests.values()].filter((r) => !resolved.has(r.requestId));
189+
}
190+
155191
function buildCloudDefaultConfigOptions(
156192
initialMode: string | undefined,
157193
adapter: Adapter = "claude",
@@ -1463,7 +1499,7 @@ export class SessionService {
14631499

14641500
private handleCloudPermissionRequest(
14651501
taskRunId: string,
1466-
update: CloudTaskPermissionRequestUpdate,
1502+
update: DerivedPermissionRequest,
14671503
): void {
14681504
log.info("Cloud permission request received", {
14691505
taskRunId,
@@ -1497,6 +1533,15 @@ export class SessionService {
14971533
notifyPermissionRequest(session.taskTitle, session.taskId);
14981534
}
14991535

1536+
private surfacePersistedPendingPermissions(
1537+
taskRunId: string,
1538+
entries: StoredLogEntry[],
1539+
): void {
1540+
for (const request of derivePendingPermissionRequests(entries)) {
1541+
this.handleCloudPermissionRequest(taskRunId, request);
1542+
}
1543+
}
1544+
15001545
// --- Prompt Handling ---
15011546

15021547
/**
@@ -3552,6 +3597,10 @@ export class SessionService {
35523597
}
35533598
}
35543599

3600+
if (update.kind === "snapshot" && !isTerminalStatus(update.status)) {
3601+
this.surfacePersistedPendingPermissions(taskRunId, update.newEntries);
3602+
}
3603+
35553604
// NOTE: Don't auto-flush on `!isPromptPending && queue.length > 0` here.
35563605
// Setup-phase log batches (`_posthog/progress`, `_posthog/console`) stream
35573606
// in BEFORE the agent emits its initial `session/prompt` request, so

packages/agent/src/acp-extensions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ export const POSTHOG_NOTIFICATIONS = {
6969

7070
/** Response to a relayed permission request (plan approval, question) */
7171
PERMISSION_RESPONSE: "_posthog/permission_response",
72+
73+
/** Permission request raised by the agent, persisted to the log so a reconnecting client can recover its requestId */
74+
PERMISSION_REQUEST: "_posthog/permission_request",
75+
76+
/** Permission request resolved, persisted so a reconnecting client can tell it is no longer pending */
77+
PERMISSION_RESOLVED: "_posthog/permission_resolved",
7278
} as const;
7379

7480
/**

packages/agent/src/server/agent-server.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export class AgentServer {
253253
outcome: { outcome: "selected"; optionId: string };
254254
_meta?: Record<string, unknown>;
255255
}) => void;
256+
toolCallId?: string;
256257
}
257258
>();
258259

@@ -2508,6 +2509,7 @@ ${signedCommitInstructions}
25082509
_meta?: Record<string, unknown>;
25092510
}> {
25102511
const requestId = crypto.randomUUID();
2512+
const toolCallId = params.toolCall?.toolCallId as string | undefined;
25112513

25122514
this.broadcastEvent({
25132515
type: "permission_request",
@@ -2516,11 +2518,33 @@ ${signedCommitInstructions}
25162518
toolCall: params.toolCall,
25172519
});
25182520

2521+
// Persist the request so a client that connects after the live event can
2522+
// recover the requestId from the log and re-surface the prompt.
2523+
this.persistPermissionLifecycle(POSTHOG_NOTIFICATIONS.PERMISSION_REQUEST, {
2524+
requestId,
2525+
toolCallId,
2526+
options: params.options,
2527+
toolCall: params.toolCall,
2528+
});
2529+
25192530
return new Promise((resolve) => {
2520-
this.pendingPermissions.set(requestId, { resolve });
2531+
this.pendingPermissions.set(requestId, { resolve, toolCallId });
25212532
});
25222533
}
25232534

2535+
private persistPermissionLifecycle(
2536+
method: string,
2537+
params: Record<string, unknown>,
2538+
): void {
2539+
if (!this.session) return;
2540+
// appendRawLine wraps the line in the {type, timestamp, notification}
2541+
// envelope, so pass the bare notification (matching broadcastTurnComplete).
2542+
this.session.logWriter.appendRawLine(
2543+
this.session.payload.run_id,
2544+
JSON.stringify({ jsonrpc: "2.0", method, params }),
2545+
);
2546+
}
2547+
25242548
private resolvePermission(
25252549
requestId: string,
25262550
optionId: string,
@@ -2532,6 +2556,12 @@ ${signedCommitInstructions}
25322556

25332557
this.pendingPermissions.delete(requestId);
25342558

2559+
this.persistPermissionLifecycle(POSTHOG_NOTIFICATIONS.PERMISSION_RESOLVED, {
2560+
requestId,
2561+
toolCallId: pending.toolCallId,
2562+
optionId,
2563+
});
2564+
25352565
const meta: Record<string, unknown> = {};
25362566
if (customInput) meta.customInput = customInput;
25372567
if (answers) meta.answers = answers;

packages/agent/src/server/question-relay.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,57 @@ describe("Question relay", () => {
386386
});
387387
});
388388

389+
describe("permission lifecycle persisted to log", () => {
390+
it("persists the request (with requestId) and its resolution", async () => {
391+
const appendRawLine = vi.fn();
392+
const srv = server as TestableAgentServer & {
393+
relayPermissionToClient: (p: {
394+
options: unknown[];
395+
toolCall?: unknown;
396+
}) => Promise<{ outcome: { outcome: string; optionId: string } }>;
397+
resolvePermission: (requestId: string, optionId: string) => boolean;
398+
session: {
399+
payload: typeof TEST_PAYLOAD;
400+
sseController: null;
401+
logWriter: { appendRawLine: typeof appendRawLine };
402+
};
403+
};
404+
srv.session = {
405+
payload: TEST_PAYLOAD,
406+
sseController: null,
407+
logWriter: { appendRawLine },
408+
};
409+
410+
const logged = (method: string) =>
411+
appendRawLine.mock.calls
412+
.map(([, line]) => JSON.parse(line))
413+
.find((n) => n?.method === method);
414+
415+
const promise = srv.relayPermissionToClient({
416+
options: [{ kind: "allow_once", optionId: "allow", name: "Allow" }],
417+
toolCall: { toolCallId: "tool-1", title: "Ready to code?" },
418+
});
419+
420+
const request = logged("_posthog/permission_request");
421+
expect(request).toBeTruthy();
422+
expect(typeof request.params.requestId).toBe("string");
423+
expect(request.params.toolCallId).toBe("tool-1");
424+
const requestId = request.params.requestId;
425+
426+
expect(srv.resolvePermission(requestId, "allow")).toBe(true);
427+
428+
const resolved = logged("_posthog/permission_resolved");
429+
expect(resolved).toBeTruthy();
430+
expect(resolved.params.requestId).toBe(requestId);
431+
expect(resolved.params.toolCallId).toBe("tool-1");
432+
expect(resolved.params.optionId).toBe("allow");
433+
434+
await expect(promise).resolves.toMatchObject({
435+
outcome: { outcome: "selected", optionId: "allow" },
436+
});
437+
});
438+
});
439+
389440
describe("relayAgentResponse duplicate suppression", () => {
390441
it("skips relay when questionRelayedToSlack is set", async () => {
391442
const relaySpy = vi

0 commit comments

Comments
 (0)