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", () => {