Skip to content

Commit b15cdd4

Browse files
authored
fix(cloud-task): resume a terminal cloud run with the selected question answer
When a cloud run terminalizes with an unanswered question, its log carries a permission_request with no resolution, so the recovered card still renders — but clicking an answer sent a permission_response command that only proxies to an active sandbox. complete_task has already drained the pending permission promise, so the answer went nowhere. Route answers on terminal cloud runs through the same path as a follow-up message instead: format the selected answer (question quoted above it) and hand it to sendCloudPrompt, which resumes the thread as a fresh run seeded with the message via resumeCloudRun. Plain approvals on a dead run are dropped rather than spinning a pointless run, and cancelling a stale card resolves locally without proxying a command. Generated-By: PostHog Code Task-Id: a25fda42-de0e-402e-9392-35c4fcc20bf3
1 parent d9ec3c8 commit b15cdd4

4 files changed

Lines changed: 332 additions & 0 deletions

File tree

packages/core/src/sessions/permissionResponse.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { PermissionRequest } from "@posthog/shared";
22
import { describe, expect, it } from "vitest";
33
import {
4+
formatPermissionAnswerPrompt,
45
isOtherPermissionOption,
56
planPermissionResponse,
67
} from "./permissionResponse";
@@ -78,3 +79,74 @@ describe("planPermissionResponse", () => {
7879
expect(plan.resendPromptText).toBeNull();
7980
});
8081
});
82+
83+
describe("formatPermissionAnswerPrompt", () => {
84+
const questionPermission = (questions: Array<{ question: string }>) =>
85+
({
86+
taskRunId: "run-1",
87+
receivedAt: 0,
88+
toolCall: {
89+
toolCallId: "tool-1",
90+
_meta: { codeToolKind: "question", questions },
91+
},
92+
options: [
93+
{ optionId: "option_0", name: "MIT", kind: "allow_once" },
94+
{ optionId: "option_1", name: "Apache 2.0", kind: "allow_once" },
95+
],
96+
}) as unknown as PermissionRequest;
97+
98+
it("quotes each question above its answer", () => {
99+
const prompt = formatPermissionAnswerPrompt(
100+
questionPermission([{ question: "Which license should I use?" }]),
101+
"option_0",
102+
undefined,
103+
{ "Which license should I use?": "MIT" },
104+
);
105+
expect(prompt).toBe(
106+
"Answering your earlier question:\n\n> Which license should I use?\n\nMIT",
107+
);
108+
});
109+
110+
it("carries every entry of a multi-question answers map", () => {
111+
const prompt = formatPermissionAnswerPrompt(
112+
questionPermission([{ question: "Q1?" }, { question: "Q2?" }]),
113+
"option_0",
114+
undefined,
115+
{ "Q1?": "A1", "Q2?": "A2" },
116+
);
117+
expect(prompt).toBe(
118+
"Answering your earlier questions:\n\n> Q1?\n\nA1\n\n> Q2?\n\nA2",
119+
);
120+
});
121+
122+
it("falls back to the picked option label when no answers map is sent", () => {
123+
const prompt = formatPermissionAnswerPrompt(
124+
questionPermission([{ question: "Which license should I use?" }]),
125+
"option_1",
126+
);
127+
expect(prompt).toBe(
128+
"Answering your earlier question:\n\n> Which license should I use?\n\nApache 2.0",
129+
);
130+
});
131+
132+
it("uses free-text custom input for a question", () => {
133+
const prompt = formatPermissionAnswerPrompt(
134+
questionPermission([{ question: "Which license should I use?" }]),
135+
"_other",
136+
"BSD, actually",
137+
);
138+
expect(prompt).toBe(
139+
"Answering your earlier question:\n\n> Which license should I use?\n\nBSD, actually",
140+
);
141+
});
142+
143+
it("returns null for a plain approval so no resume run is spun", () => {
144+
const approval = makePermission([
145+
{ optionId: "allow", kind: "allow_once" },
146+
]);
147+
expect(formatPermissionAnswerPrompt(approval, "allow")).toBeNull();
148+
expect(
149+
formatPermissionAnswerPrompt(approval, "reject", "not like this"),
150+
).toBeNull();
151+
});
152+
});

packages/core/src/sessions/permissionResponse.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,92 @@ export function planPermissionResponse(
5252
resendPromptText: null,
5353
};
5454
}
55+
56+
interface QuestionMeta {
57+
codeToolKind?: unknown;
58+
questions?: unknown;
59+
}
60+
61+
function questionMeta(
62+
permission: PermissionRequest | undefined,
63+
): QuestionMeta | null {
64+
const meta = permission?.toolCall?._meta;
65+
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
66+
return null;
67+
}
68+
return meta as QuestionMeta;
69+
}
70+
71+
export function isQuestionPermission(
72+
permission: PermissionRequest | undefined,
73+
): boolean {
74+
return questionMeta(permission)?.codeToolKind === "question";
75+
}
76+
77+
function firstQuestionText(
78+
permission: PermissionRequest | undefined,
79+
): string | null {
80+
const questions = questionMeta(permission)?.questions;
81+
if (!Array.isArray(questions)) {
82+
return null;
83+
}
84+
for (const entry of questions) {
85+
const question =
86+
entry && typeof entry === "object"
87+
? (entry as { question?: unknown }).question
88+
: undefined;
89+
if (typeof question === "string" && question.trim()) {
90+
return question.trim();
91+
}
92+
}
93+
return null;
94+
}
95+
96+
/**
97+
* Builds the follow-up prompt that carries a question's selected answer into a
98+
* resumed run. Once a cloud run has terminalized, its sandbox (and the pending
99+
* permission promise inside it) is gone, so a permission_response command has
100+
* nothing left to resolve — the answer travels as a fresh user message on a
101+
* resume run instead. Each question is quoted above its answer so the agent can
102+
* match the reply against the question(s) it parked before the run ended.
103+
* Returns null when there is nothing meaningful to carry forward (a plain
104+
* approval), so callers can drop the response rather than spin a pointless run.
105+
*/
106+
export function formatPermissionAnswerPrompt(
107+
permission: PermissionRequest | undefined,
108+
optionId: string,
109+
customInput?: string,
110+
answers?: Record<string, string>,
111+
): string | null {
112+
const blocks: string[] = [];
113+
for (const [question, answer] of Object.entries(answers ?? {})) {
114+
if (question.trim() && answer.trim()) {
115+
blocks.push(`> ${question.trim()}\n\n${answer.trim()}`);
116+
}
117+
}
118+
119+
if (blocks.length === 0) {
120+
if (!isQuestionPermission(permission)) {
121+
return null;
122+
}
123+
// A question answered without an answers map: free text, or a bare option pick.
124+
const answerText =
125+
customInput?.trim() ||
126+
permission?.options
127+
.find((option) => option.optionId === optionId)
128+
?.name?.trim();
129+
if (!answerText) {
130+
return null;
131+
}
132+
const question = firstQuestionText(permission);
133+
blocks.push(question ? `> ${question}\n\n${answerText}` : answerText);
134+
} else {
135+
const extraInput = customInput?.trim();
136+
if (extraInput && !blocks.some((block) => block.includes(extraInput))) {
137+
blocks.push(extraInput);
138+
}
139+
}
140+
141+
const noun = blocks.length > 1 ? "questions" : "question";
142+
return `Answering your earlier ${noun}:\n\n${blocks.join("\n\n")}`;
143+
}

packages/core/src/sessions/sessionService.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ import {
6565
routeLocalConnect,
6666
} from "./connectRouting";
6767
import {
68+
formatPermissionAnswerPrompt,
6869
type PermissionSelectionPlan,
6970
planPermissionResponse,
7071
} from "./permissionResponse";
@@ -3138,6 +3139,35 @@ export class SessionService {
31383139
this.resolvePermission(session, toolCallId);
31393140

31403141
try {
3142+
if (session.isCloud && isTerminalStatus(session.cloudStatus)) {
3143+
// The run is over: complete_task drained the sandbox's pending permission
3144+
// promise, and the permission_response command only proxies to an active
3145+
// sandbox — there is nothing left for it to resolve. Carry the selected
3146+
// answer forward as a user message instead: sendCloudPrompt routes
3147+
// terminal runs through resumeCloudRun, which spins a fresh run seeded
3148+
// with it.
3149+
this.cloudPermissionRequestIds.delete(toolCallId);
3150+
const answerPrompt = formatPermissionAnswerPrompt(
3151+
permission,
3152+
optionId,
3153+
customInput,
3154+
answers,
3155+
);
3156+
if (!answerPrompt) {
3157+
this.d.log.info(
3158+
"Dropped permission response for terminal cloud run",
3159+
{ taskId, toolCallId, optionId },
3160+
);
3161+
return;
3162+
}
3163+
await this.sendCloudPrompt(session, answerPrompt);
3164+
this.d.log.info("Permission answer resumed terminal cloud run", {
3165+
taskId,
3166+
toolCallId,
3167+
optionId,
3168+
});
3169+
return;
3170+
}
31413171
if (session.isCloud && cloudRequestId) {
31423172
this.cloudPermissionRequestIds.delete(toolCallId);
31433173
await this.sendCloudCommand(session, "permission_response", {
@@ -3195,6 +3225,12 @@ export class SessionService {
31953225
this.resolvePermission(session, toolCallId);
31963226

31973227
try {
3228+
if (session.isCloud && isTerminalStatus(session.cloudStatus)) {
3229+
// The run is over — the card was resolved locally above and there is no
3230+
// live permission promise left to reject.
3231+
this.cloudPermissionRequestIds.delete(toolCallId);
3232+
return;
3233+
}
31983234
if (session.isCloud && cloudRequestId) {
31993235
this.cloudPermissionRequestIds.delete(toolCallId);
32003236
await this.sendCloudCommand(session, "permission_response", {

packages/ui/src/features/sessions/sessionServiceHost.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4984,6 +4984,128 @@ describe("SessionService", () => {
49844984
answers: undefined,
49854985
});
49864986
});
4987+
4988+
const mockTerminalCloudRun = () => {
4989+
mockAuthenticatedClient.getTaskRun.mockResolvedValue({
4990+
id: "run-123",
4991+
task: "task-123",
4992+
team: 123,
4993+
branch: "feature/cloud-run",
4994+
environment: "cloud",
4995+
status: "completed",
4996+
log_url: "https://example.com/logs/run-123",
4997+
error_message: null,
4998+
output: {},
4999+
state: {},
5000+
created_at: "2026-04-14T00:00:00Z",
5001+
updated_at: "2026-04-14T00:00:00Z",
5002+
completed_at: "2026-04-14T00:05:00Z",
5003+
});
5004+
mockAuthenticatedClient.runTaskInCloud.mockResolvedValue(
5005+
createMockTask({
5006+
latest_run: {
5007+
id: "run-456",
5008+
task: "task-123",
5009+
team: 123,
5010+
branch: "feature/cloud-run",
5011+
environment: "cloud",
5012+
status: "queued",
5013+
log_url: "https://example.com/logs/run-456",
5014+
error_message: null,
5015+
output: {},
5016+
state: {},
5017+
created_at: "2026-04-14T00:06:00Z",
5018+
updated_at: "2026-04-14T00:06:00Z",
5019+
completed_at: null,
5020+
} as Task["latest_run"],
5021+
}),
5022+
);
5023+
};
5024+
5025+
it("resumes a terminal cloud run with the selected answer as the prompt", async () => {
5026+
const service = getSessionService();
5027+
const permissions = new Map([
5028+
[
5029+
"tool-1",
5030+
{
5031+
taskRunId: "run-123",
5032+
receivedAt: Date.now(),
5033+
toolCall: {
5034+
toolCallId: "tool-1",
5035+
_meta: {
5036+
codeToolKind: "question",
5037+
questions: [{ question: "Which license should I use?" }],
5038+
},
5039+
},
5040+
options: [
5041+
{ optionId: "option_0", name: "MIT", kind: "allow_once" },
5042+
],
5043+
},
5044+
],
5045+
]);
5046+
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
5047+
createMockSession({
5048+
isCloud: true,
5049+
cloudStatus: "completed",
5050+
cloudBranch: "feature/cloud-run",
5051+
pendingPermissions: permissions as AgentSession["pendingPermissions"],
5052+
}),
5053+
);
5054+
mockTerminalCloudRun();
5055+
5056+
await service.respondToPermission(
5057+
"task-123",
5058+
"tool-1",
5059+
"option_0",
5060+
undefined,
5061+
{
5062+
"Which license should I use?": "MIT",
5063+
},
5064+
);
5065+
5066+
// The dead run's permission promise can't be resolved — no command is proxied.
5067+
expect(mockTrpcCloudTask.sendCommand.mutate).not.toHaveBeenCalled();
5068+
expect(mockTrpcAgent.respondToPermission.mutate).not.toHaveBeenCalled();
5069+
expect(mockAuthenticatedClient.runTaskInCloud).toHaveBeenCalledWith(
5070+
"task-123",
5071+
"feature/cloud-run",
5072+
expect.objectContaining({
5073+
resumeFromRunId: "run-123",
5074+
pendingUserMessage:
5075+
"Answering your earlier question:\n\n> Which license should I use?\n\nMIT",
5076+
}),
5077+
);
5078+
});
5079+
5080+
it("drops a plain approval on a terminal cloud run instead of resuming", async () => {
5081+
const service = getSessionService();
5082+
const permissions = new Map([
5083+
[
5084+
"tool-1",
5085+
{
5086+
taskRunId: "run-123",
5087+
receivedAt: Date.now(),
5088+
toolCall: { toolCallId: "tool-1", kind: "execute" },
5089+
options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }],
5090+
},
5091+
],
5092+
]);
5093+
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
5094+
createMockSession({
5095+
isCloud: true,
5096+
cloudStatus: "completed",
5097+
pendingPermissions: permissions as AgentSession["pendingPermissions"],
5098+
}),
5099+
);
5100+
mockTerminalCloudRun();
5101+
5102+
await service.respondToPermission("task-123", "tool-1", "allow");
5103+
5104+
expect(mockSessionStoreSetters.setPendingPermissions).toHaveBeenCalled();
5105+
expect(mockTrpcCloudTask.sendCommand.mutate).not.toHaveBeenCalled();
5106+
expect(mockTrpcAgent.respondToPermission.mutate).not.toHaveBeenCalled();
5107+
expect(mockAuthenticatedClient.runTaskInCloud).not.toHaveBeenCalled();
5108+
});
49875109
});
49885110

49895111
describe("cancelPermission", () => {
@@ -5010,6 +5132,19 @@ describe("SessionService", () => {
50105132
toolCallId: "tool-1",
50115133
});
50125134
});
5135+
5136+
it("resolves locally without proxying a command on a terminal cloud run", async () => {
5137+
const service = getSessionService();
5138+
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
5139+
createMockSession({ isCloud: true, cloudStatus: "completed" }),
5140+
);
5141+
5142+
await service.cancelPermission("task-123", "tool-1");
5143+
5144+
expect(mockSessionStoreSetters.setPendingPermissions).toHaveBeenCalled();
5145+
expect(mockTrpcCloudTask.sendCommand.mutate).not.toHaveBeenCalled();
5146+
expect(mockTrpcAgent.cancelPermission.mutate).not.toHaveBeenCalled();
5147+
});
50135148
});
50145149

50155150
describe("setSessionConfigOption", () => {

0 commit comments

Comments
 (0)