From d882085016542abc421f1c8de113d650c3ab200c Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 7 Jul 2026 10:34:55 +0100 Subject: [PATCH] fix(cloud-task): resume a terminal cloud run with the selected question answer When a terminal cloud run has a pending AskUserQuestion request, clicking the recovered card cannot proxy a permission_response to the dead sandbox. Route selected question answers through the terminal resume path as the next user message, refresh stale run status before responding, and recover pending question cards from persisted session logs after app restart. Keep resumed single-answer prompts user-facing by sending only the selected answer, while still dropping plain approvals on dead runs instead of spinning a pointless resume. Generated-By: PostHog Code --- .../derivePendingPermissionRequests.test.ts | 51 +++ .../src/sessions/permissionResponse.test.ts | 69 ++++ .../core/src/sessions/permissionResponse.ts | 73 ++++ packages/core/src/sessions/sessionService.ts | 204 +++++++++++- .../sessions/sessionServiceHost.test.ts | 315 ++++++++++++++++++ 5 files changed, 704 insertions(+), 8 deletions(-) diff --git a/packages/core/src/sessions/derivePendingPermissionRequests.test.ts b/packages/core/src/sessions/derivePendingPermissionRequests.test.ts index d1a5d822a0..8161d50a40 100644 --- a/packages/core/src/sessions/derivePendingPermissionRequests.test.ts +++ b/packages/core/src/sessions/derivePendingPermissionRequests.test.ts @@ -6,6 +6,30 @@ import { } from "./sessionService"; describe("derivePendingPermissionRequests", () => { + const sdkSession = (taskRunId: string): StoredLogEntry => ({ + type: "notification", + notification: { + method: "_posthog/sdk_session", + params: { taskRunId, sessionId: "session-1", adapter: "claude" }, + }, + }); + const runStarted = (taskRunId: string): StoredLogEntry => ({ + type: "notification", + notification: { + method: "_posthog/run_started", + params: { runId: taskRunId, taskId: "task-1" }, + }, + }); + const prompt = (content: string): StoredLogEntry => ({ + type: "notification", + notification: { + method: "session/prompt", + params: { + sessionId: "session-1", + prompt: [{ type: "text", text: content }], + }, + }, + }); const request = (requestId: string, toolCallId: string): StoredLogEntry => ({ type: "notification", notification: { @@ -65,6 +89,33 @@ describe("derivePendingPermissionRequests", () => { expect(pending).toEqual([]); }); + + it("ignores predecessor-run questions when deriving pending requests for a resumed run", () => { + const pending = derivePendingPermissionRequests( + [ + sdkSession("run-before"), + runStarted("run-before"), + request("r1", "t1"), + sdkSession("run-after"), + runStarted("run-after"), + prompt( + "This is the user's selected answer to the AskUserQuestion prompt that was pending before this cloud run resumed.", + ), + ], + { taskRunId: "run-after" }, + ); + + expect(pending).toEqual([]); + }); + + it("keeps current-run questions when scoped derivation matches the run", () => { + const pending = derivePendingPermissionRequests( + [sdkSession("run-1"), runStarted("run-1"), request("r1", "t1")], + { taskRunId: "run-1" }, + ); + + expect(pending.map((p) => p.requestId)).toEqual(["r1"]); + }); }); describe("isPermissionRequestAlreadySurfaced", () => { diff --git a/packages/core/src/sessions/permissionResponse.test.ts b/packages/core/src/sessions/permissionResponse.test.ts index 7b6e8f26bb..228a28142d 100644 --- a/packages/core/src/sessions/permissionResponse.test.ts +++ b/packages/core/src/sessions/permissionResponse.test.ts @@ -1,6 +1,7 @@ import type { PermissionRequest } from "@posthog/shared"; import { describe, expect, it } from "vitest"; import { + formatPermissionAnswerPrompt, isOtherPermissionOption, planPermissionResponse, } from "./permissionResponse"; @@ -78,3 +79,71 @@ describe("planPermissionResponse", () => { expect(plan.resendPromptText).toBeNull(); }); }); + +describe("formatPermissionAnswerPrompt", () => { + const questionPermission = (questions: Array<{ question: string }>) => + ({ + taskRunId: "run-1", + receivedAt: 0, + toolCall: { + toolCallId: "tool-1", + _meta: { codeToolKind: "question", questions }, + }, + options: [ + { optionId: "option_0", name: "MIT", kind: "allow_once" }, + { optionId: "option_1", name: "Apache 2.0", kind: "allow_once" }, + ], + }) as unknown as PermissionRequest; + + it("quotes each question above its answer", () => { + const prompt = formatPermissionAnswerPrompt( + questionPermission([{ question: "Which license should I use?" }]), + "option_0", + undefined, + { "Which license should I use?": "MIT" }, + ); + expect(prompt).toBe("MIT"); + }); + + it("carries every entry of a multi-question answers map", () => { + const prompt = formatPermissionAnswerPrompt( + questionPermission([{ question: "Q1?" }, { question: "Q2?" }]), + "option_0", + undefined, + { "Q1?": "A1", "Q2?": "A2" }, + ); + expect(prompt).toBe("1. A1\n2. A2"); + }); + + it("falls back to the picked option label when no answers map is sent", () => { + const prompt = formatPermissionAnswerPrompt( + questionPermission([{ question: "Which license should I use?" }]), + "option_1", + ); + expect(prompt).toBe("Apache 2.0"); + }); + + it("uses free-text custom input for a question", () => { + const prompt = formatPermissionAnswerPrompt( + questionPermission([{ question: "Which license should I use?" }]), + "_other", + "BSD, actually", + ); + expect(prompt).toBe("BSD, actually"); + }); + + it.each([ + ["plain approval", "allow", undefined], + ["plain rejection with feedback", "reject", "not like this"], + ])( + "returns null for %s so no resume run is spun", + (_caseName, optionId, customInput) => { + const approval = makePermission([ + { optionId: "allow", kind: "allow_once" }, + ]); + expect( + formatPermissionAnswerPrompt(approval, optionId, customInput), + ).toBeNull(); + }, + ); +}); diff --git a/packages/core/src/sessions/permissionResponse.ts b/packages/core/src/sessions/permissionResponse.ts index 44db453815..eef81a7f10 100644 --- a/packages/core/src/sessions/permissionResponse.ts +++ b/packages/core/src/sessions/permissionResponse.ts @@ -52,3 +52,76 @@ export function planPermissionResponse( resendPromptText: null, }; } + +interface QuestionMeta { + codeToolKind?: unknown; + questions?: unknown; +} + +function questionMeta( + permission: PermissionRequest | undefined, +): QuestionMeta | null { + const meta = permission?.toolCall?._meta; + if (!meta || typeof meta !== "object" || Array.isArray(meta)) { + return null; + } + return meta as QuestionMeta; +} + +export function isQuestionPermission( + permission: PermissionRequest | undefined, +): boolean { + return questionMeta(permission)?.codeToolKind === "question"; +} + +/** + * Builds the follow-up prompt that carries a question's selected answer into a + * resumed run. Once a cloud run has terminalized, its sandbox and the pending + * permission promise inside it are gone, so a permission_response command has + * nothing left to resolve. The answer travels as a fresh user message on a + * resume run instead. + * Returns null when there is nothing meaningful to carry forward (a plain + * approval), so callers can drop the response rather than spin a pointless run. + */ +export function formatPermissionAnswerPrompt( + permission: PermissionRequest | undefined, + optionId: string, + customInput?: string, + answers?: Record, +): string | null { + const selectedAnswers: string[] = []; + for (const [question, answer] of Object.entries(answers ?? {})) { + if (question.trim() && answer.trim()) { + selectedAnswers.push(answer.trim()); + } + } + + if (selectedAnswers.length === 0) { + if (!isQuestionPermission(permission)) { + return null; + } + // A question answered without an answers map: free text, or a bare option pick. + const answerText = + customInput?.trim() || + permission?.options + .find((option) => option.optionId === optionId) + ?.name?.trim(); + if (!answerText) { + return null; + } + selectedAnswers.push(answerText); + } else { + const extraInput = customInput?.trim(); + if (extraInput && !selectedAnswers.includes(extraInput)) { + selectedAnswers.push(extraInput); + } + } + + if (selectedAnswers.length === 1) { + return selectedAnswers[0] ?? null; + } + + return selectedAnswers + .map((answer, index) => `${index + 1}. ${answer}`) + .join("\n"); +} diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 4c960bdca2..d0d3bc8720 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -65,6 +65,7 @@ import { routeLocalConnect, } from "./connectRouting"; import { + formatPermissionAnswerPrompt, type PermissionSelectionPlan, planPermissionResponse, } from "./permissionResponse"; @@ -443,12 +444,57 @@ type DerivedPermissionRequest = Pick< "requestId" | "toolCall" | "options" >; +function getEntryTaskRunMarker(entry: StoredLogEntry): string | undefined { + const method = entry.notification?.method; + if (!method) return undefined; + + const params = (entry.notification?.params ?? {}) as { + runId?: unknown; + taskRunId?: unknown; + }; + + if ( + isNotification(method, POSTHOG_NOTIFICATIONS.SDK_SESSION) && + typeof params.taskRunId === "string" + ) { + return params.taskRunId; + } + + if ( + isNotification(method, POSTHOG_NOTIFICATIONS.RUN_STARTED) && + typeof params.runId === "string" + ) { + return params.runId; + } + + return undefined; +} + +function entriesScopedToTaskRun( + entries: StoredLogEntry[], + taskRunId: string | undefined, +): StoredLogEntry[] { + if (!taskRunId || !entries.some((entry) => getEntryTaskRunMarker(entry))) { + return entries; + } + + let currentTaskRunId: string | undefined; + return entries.filter((entry) => { + const marker = getEntryTaskRunMarker(entry); + if (marker) { + currentTaskRunId = marker; + } + return currentTaskRunId === taskRunId; + }); +} + export function derivePendingPermissionRequests( entries: StoredLogEntry[], + options?: { taskRunId?: string }, ): DerivedPermissionRequest[] { const requests = new Map(); const resolved = new Set(); - for (const entry of entries) { + for (const entry of entriesScopedToTaskRun(entries, options?.taskRunId)) { const method = entry.notification?.method; if (!method) continue; const params = (entry.notification?.params ?? {}) as { @@ -565,6 +611,7 @@ export class SessionService { string, { startedAtTs: number; agentTextChunks: number; agentOutputEvents: number } >(); + private pendingPermissionHydratedRuns = new Set(); private idleKilledSubscription: { unsubscribe: () => void } | null = null; /** * Cached preview-config-options responses keyed by `${apiHost}::${adapter}`. @@ -2153,7 +2200,9 @@ export class SessionService { taskRunId: string, entries: StoredLogEntry[], ): void { - for (const request of derivePendingPermissionRequests(entries)) { + for (const request of derivePendingPermissionRequests(entries, { + taskRunId, + })) { this.handleCloudPermissionRequest(taskRunId, request); } } @@ -3095,6 +3144,72 @@ export class SessionService { }); } + private async refreshCloudRunStatus( + session: AgentSession, + ): Promise { + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind !== "ready") { + return null; + } + + try { + const run = await authStatus.auth.client.getTaskRun( + session.taskId, + session.taskRunId, + ); + this.d.store.updateSession(session.taskRunId, { + cloudStatus: run.status, + cloudStage: run.stage ?? null, + cloudOutput: run.output ?? null, + cloudErrorMessage: run.error_message, + logUrl: run.log_url ?? session.logUrl, + }); + return run.status; + } catch (error) { + this.d.log.warn("Failed to refresh cloud run status", { + taskId: session.taskId, + taskRunId: session.taskRunId, + error: String(error), + }); + return null; + } + } + + private async resumeTerminalCloudPermissionResponse( + session: AgentSession, + permission: PermissionRequest | undefined, + toolCallId: string, + optionId: string, + customInput?: string, + answers?: Record, + cloudStatus?: TaskRunStatus | null, + ): Promise { + this.cloudPermissionRequestIds.delete(toolCallId); + const answerPrompt = formatPermissionAnswerPrompt( + permission, + optionId, + customInput, + answers, + ); + if (!answerPrompt) { + this.d.log.info("Dropped permission response for terminal cloud run", { + taskId: session.taskId, + toolCallId, + optionId, + }); + return; + } + await this.sendCloudPrompt( + { ...session, cloudStatus: cloudStatus ?? session.cloudStatus }, + answerPrompt, + ); + this.d.log.info("Permission answer resumed terminal cloud run", { + taskId: session.taskId, + toolCallId, + optionId, + }); + } + // --- Permissions --- private resolvePermission(session: AgentSession, toolCallId: string): void { @@ -3138,14 +3253,54 @@ export class SessionService { this.resolvePermission(session, toolCallId); try { - if (session.isCloud && cloudRequestId) { - this.cloudPermissionRequestIds.delete(toolCallId); - await this.sendCloudCommand(session, "permission_response", { - requestId: cloudRequestId, + const refreshedCloudStatus = + session.isCloud && !isTerminalStatus(session.cloudStatus) + ? await this.refreshCloudRunStatus(session) + : null; + const terminalCloudStatus = isTerminalStatus(session.cloudStatus) + ? session.cloudStatus + : refreshedCloudStatus; + + if (session.isCloud && isTerminalStatus(terminalCloudStatus)) { + // The run is over: complete_task drained the sandbox's pending + // permission promise, and permission_response only proxies to an active + // sandbox. Carry the selected answer forward as a user message instead. + await this.resumeTerminalCloudPermissionResponse( + session, + permission, + toolCallId, optionId, customInput, answers, - }); + terminalCloudStatus, + ); + return; + } + if (session.isCloud && cloudRequestId) { + this.cloudPermissionRequestIds.delete(toolCallId); + try { + await this.sendCloudCommand(session, "permission_response", { + requestId: cloudRequestId, + optionId, + customInput, + answers, + }); + } catch (error) { + const latestCloudStatus = await this.refreshCloudRunStatus(session); + if (isTerminalStatus(latestCloudStatus)) { + await this.resumeTerminalCloudPermissionResponse( + session, + permission, + toolCallId, + optionId, + customInput, + answers, + latestCloudStatus, + ); + return; + } + throw error; + } } else { await this.d.trpc.agent.respondToPermission.mutate({ taskRunId: session.taskRunId, @@ -3195,6 +3350,12 @@ export class SessionService { this.resolvePermission(session, toolCallId); try { + if (session.isCloud && isTerminalStatus(session.cloudStatus)) { + // The run is over — the card was resolved locally above and there is no + // live permission promise left to reject. + this.cloudPermissionRequestIds.delete(toolCallId); + return; + } if (session.isCloud && cloudRequestId) { this.cloudPermissionRequestIds.delete(toolCallId); await this.sendCloudCommand(session, "permission_response", { @@ -3668,11 +3829,25 @@ export class SessionService { // the stop-and-restart below. if (!existingWatcher) { const hydrated = this.d.store.getSessionByTaskId(taskId); + const needsPersistedPermissionHydration = + hydrated?.taskRunId === taskRunId && + hydrated.pendingPermissions.size === 0 && + !this.pendingPermissionHydratedRuns.has(taskRunId); if ( hydrated?.taskRunId === taskRunId && isTerminalStatus(hydrated.cloudStatus) && hydrated.processedLineCount !== undefined ) { + if (needsPersistedPermissionHydration) { + this.hydrateCloudTaskSessionFromLogs( + taskId, + taskRunId, + logUrl, + taskDescription, + runStatus, + runState, + ); + } return () => {}; } } @@ -3693,11 +3868,19 @@ export class SessionService { existing?.taskRunId === taskRunId && existing.events.length > 0 && existing.processedLineCount === undefined; + const shouldHydratePersistedPermissions = + existing?.taskRunId === taskRunId && + existing.pendingPermissions.size === 0 && + existing.processedLineCount !== undefined && + !this.pendingPermissionHydratedRuns.has(taskRunId) && + (isTerminalStatus(existing.cloudStatus) || + (runStatus !== undefined && isTerminalStatus(runStatus))); const shouldHydrateSession = !existing || existing.taskRunId !== taskRunId || shouldResetExistingSession || - existing.events.length === 0; + existing.events.length === 0 || + shouldHydratePersistedPermissions; if ( !existing || @@ -3910,6 +4093,7 @@ export class SessionService { } if (rawEntries.length === 0) { + this.pendingPermissionHydratedRuns.add(taskRunId); return; } @@ -3919,6 +4103,8 @@ export class SessionService { session.processedLineCount !== undefined && session.processedLineCount > 0 ) { + this.surfacePersistedPendingPermissions(taskRunId, rawEntries); + this.pendingPermissionHydratedRuns.add(taskRunId); return; } @@ -3928,6 +4114,8 @@ export class SessionService { logUrl: logUrl ?? session.logUrl, processedLineCount: totalLineCount, }); + this.surfacePersistedPendingPermissions(taskRunId, rawEntries); + this.pendingPermissionHydratedRuns.add(taskRunId); // Without this the "Galumphing…" indicator stays hidden when the hydrated // baseline already contains an in-flight session/prompt — the live delta // path otherwise sees delta <= 0 and never re-evaluates the tail. diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts index 84144af24a..be96ef8179 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts @@ -3110,6 +3110,128 @@ describe("SessionService", () => { }); }); + it("restores a pending question from terminal cloud-run logs after restart", async () => { + const service = getSessionService(); + const completedSession = createMockSession({ + taskRunId: "run-123", + taskId: "task-123", + status: "disconnected", + isCloud: true, + events: [ + { + type: "acp_message", + ts: 1700000000, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { update: { sessionUpdate: "tool_call" } }, + }, + } as AcpMessage, + ], + cloudStatus: "completed", + processedLineCount: 3, + }); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + completedSession, + ); + mockSessionStoreSetters.getSessions.mockReturnValue({ + "run-123": completedSession, + }); + mockAuthenticatedClient.getTaskRunSessionLogs.mockResolvedValue([ + { + type: "notification", + notification: { + method: "_posthog/sdk_session", + params: { + taskRunId: "run-123", + sessionId: "acp-session-1", + adapter: "claude", + }, + }, + }, + { + type: "notification", + notification: { + method: "_posthog/run_started", + params: { + sessionId: "acp-session-1", + runId: "run-123", + taskId: "task-123", + }, + }, + }, + { + type: "notification", + notification: { + method: "_posthog/permission_request", + params: { + requestId: "request-1", + toolCall: { + toolCallId: "tool-1", + title: "What animal do you prefer?", + kind: "other", + _meta: { + codeToolKind: "question", + questions: [ + { + question: "What animal do you prefer?", + options: [ + { label: "cats", description: "Cats" }, + { label: "dogs", description: "Dogs" }, + ], + }, + ], + }, + }, + options: [ + { optionId: "option_0", name: "cats", kind: "allow_once" }, + { optionId: "option_1", name: "dogs", kind: "allow_once" }, + ], + }, + }, + }, + ]); + + service.watchCloudTask( + "task-123", + "run-123", + "https://api.anthropic.com", + 123, + undefined, + "https://logs.example.com/run-123", + undefined, + "claude", + undefined, + "ask about animals", + undefined, + "completed", + ); + + await vi.waitFor(() => { + expect( + mockSessionStoreSetters.setPendingPermissions, + ).toHaveBeenCalledWith("run-123", expect.any(Map)); + }); + + const permissions = mockSessionStoreSetters.setPendingPermissions.mock + .calls[0]?.[1] as Map< + string, + { taskRunId: string; options: unknown[] } + >; + expect(permissions.get("tool-1")).toEqual( + expect.objectContaining({ + taskRunId: "run-123", + options: [ + { optionId: "option_0", name: "cats", kind: "allow_once" }, + { optionId: "option_1", name: "dogs", kind: "allow_once" }, + ], + }), + ); + expect( + mockNotificationService.notifyPermissionRequest, + ).toHaveBeenCalled(); + }); + it("does NOT seed an optimistic user-message when hydration finds prior history", async () => { const service = getSessionService(); const reopenedSession = createMockSession({ @@ -4984,6 +5106,186 @@ describe("SessionService", () => { answers: undefined, }); }); + + const mockTerminalCloudRun = () => { + mockAuthenticatedClient.getTaskRun.mockResolvedValue({ + id: "run-123", + task: "task-123", + team: 123, + branch: "feature/cloud-run", + environment: "cloud", + status: "completed", + log_url: "https://example.com/logs/run-123", + error_message: null, + output: {}, + state: {}, + created_at: "2026-04-14T00:00:00Z", + updated_at: "2026-04-14T00:00:00Z", + completed_at: "2026-04-14T00:05:00Z", + }); + mockAuthenticatedClient.runTaskInCloud.mockResolvedValue( + createMockTask({ + latest_run: { + id: "run-456", + task: "task-123", + team: 123, + branch: "feature/cloud-run", + environment: "cloud", + status: "queued", + log_url: "https://example.com/logs/run-456", + error_message: null, + output: {}, + state: {}, + created_at: "2026-04-14T00:06:00Z", + updated_at: "2026-04-14T00:06:00Z", + completed_at: null, + } as Task["latest_run"], + }), + ); + }; + + const selectedAnswerPrompt = "MIT"; + + it("resumes a terminal cloud run with the selected answer as the prompt", async () => { + const service = getSessionService(); + const permissions = new Map([ + [ + "tool-1", + { + taskRunId: "run-123", + receivedAt: Date.now(), + toolCall: { + toolCallId: "tool-1", + _meta: { + codeToolKind: "question", + questions: [{ question: "Which license should I use?" }], + }, + }, + options: [ + { optionId: "option_0", name: "MIT", kind: "allow_once" }, + ], + }, + ], + ]); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "completed", + cloudBranch: "feature/cloud-run", + pendingPermissions: permissions as AgentSession["pendingPermissions"], + }), + ); + mockTerminalCloudRun(); + + await service.respondToPermission( + "task-123", + "tool-1", + "option_0", + undefined, + { + "Which license should I use?": "MIT", + }, + ); + + // The dead run's permission promise can't be resolved — no command is proxied. + expect(mockTrpcCloudTask.sendCommand.mutate).not.toHaveBeenCalled(); + expect(mockTrpcAgent.respondToPermission.mutate).not.toHaveBeenCalled(); + expect(mockAuthenticatedClient.runTaskInCloud).toHaveBeenCalledWith( + "task-123", + "feature/cloud-run", + expect.objectContaining({ + resumeFromRunId: "run-123", + pendingUserMessage: selectedAnswerPrompt, + }), + ); + }); + + it("refreshes stale cloud run status before answering a terminal question", async () => { + const service = getSessionService(); + const permissions = new Map([ + [ + "tool-1", + { + taskRunId: "run-123", + receivedAt: Date.now(), + toolCall: { + toolCallId: "tool-1", + _meta: { + codeToolKind: "question", + questions: [{ question: "Which license should I use?" }], + }, + }, + options: [ + { optionId: "option_0", name: "MIT", kind: "allow_once" }, + ], + }, + ], + ]); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "in_progress", + cloudBranch: "feature/cloud-run", + pendingPermissions: permissions as AgentSession["pendingPermissions"], + }), + ); + mockTerminalCloudRun(); + + await service.respondToPermission( + "task-123", + "tool-1", + "option_0", + undefined, + { + "Which license should I use?": "MIT", + }, + ); + + expect(mockAuthenticatedClient.getTaskRun).toHaveBeenCalledWith( + "task-123", + "run-123", + ); + expect(mockTrpcCloudTask.sendCommand.mutate).not.toHaveBeenCalled(); + expect(mockTrpcAgent.respondToPermission.mutate).not.toHaveBeenCalled(); + expect(mockAuthenticatedClient.runTaskInCloud).toHaveBeenCalledWith( + "task-123", + "feature/cloud-run", + expect.objectContaining({ + resumeFromRunId: "run-123", + pendingUserMessage: selectedAnswerPrompt, + }), + ); + }); + + it("drops a plain approval on a terminal cloud run instead of resuming", async () => { + const service = getSessionService(); + const permissions = new Map([ + [ + "tool-1", + { + taskRunId: "run-123", + receivedAt: Date.now(), + toolCall: { toolCallId: "tool-1", kind: "execute" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }, + ], + ]); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ + isCloud: true, + cloudStatus: "completed", + pendingPermissions: permissions as AgentSession["pendingPermissions"], + }), + ); + mockTerminalCloudRun(); + + await service.respondToPermission("task-123", "tool-1", "allow"); + + expect(mockSessionStoreSetters.setPendingPermissions).toHaveBeenCalled(); + expect(mockTrpcCloudTask.sendCommand.mutate).not.toHaveBeenCalled(); + expect(mockTrpcAgent.respondToPermission.mutate).not.toHaveBeenCalled(); + expect(mockAuthenticatedClient.runTaskInCloud).not.toHaveBeenCalled(); + }); }); describe("cancelPermission", () => { @@ -5010,6 +5312,19 @@ describe("SessionService", () => { toolCallId: "tool-1", }); }); + + it("resolves locally without proxying a command on a terminal cloud run", async () => { + const service = getSessionService(); + mockSessionStoreSetters.getSessionByTaskId.mockReturnValue( + createMockSession({ isCloud: true, cloudStatus: "completed" }), + ); + + await service.cancelPermission("task-123", "tool-1"); + + expect(mockSessionStoreSetters.setPendingPermissions).toHaveBeenCalled(); + expect(mockTrpcCloudTask.sendCommand.mutate).not.toHaveBeenCalled(); + expect(mockTrpcAgent.cancelPermission.mutate).not.toHaveBeenCalled(); + }); }); describe("setSessionConfigOption", () => {