Skip to content

Commit 8f04d50

Browse files
committed
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
1 parent d9ec3c8 commit 8f04d50

5 files changed

Lines changed: 699 additions & 8 deletions

File tree

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ import {
66
} from "./sessionService";
77

88
describe("derivePendingPermissionRequests", () => {
9+
const sdkSession = (taskRunId: string): StoredLogEntry => ({
10+
type: "notification",
11+
notification: {
12+
method: "_posthog/sdk_session",
13+
params: { taskRunId, sessionId: "session-1", adapter: "claude" },
14+
},
15+
});
16+
const runStarted = (taskRunId: string): StoredLogEntry => ({
17+
type: "notification",
18+
notification: {
19+
method: "_posthog/run_started",
20+
params: { runId: taskRunId, taskId: "task-1" },
21+
},
22+
});
23+
const prompt = (content: string): StoredLogEntry => ({
24+
type: "notification",
25+
notification: {
26+
method: "session/prompt",
27+
params: {
28+
sessionId: "session-1",
29+
prompt: [{ type: "text", text: content }],
30+
},
31+
},
32+
});
933
const request = (requestId: string, toolCallId: string): StoredLogEntry => ({
1034
type: "notification",
1135
notification: {
@@ -65,6 +89,33 @@ describe("derivePendingPermissionRequests", () => {
6589

6690
expect(pending).toEqual([]);
6791
});
92+
93+
it("ignores predecessor-run questions when deriving pending requests for a resumed run", () => {
94+
const pending = derivePendingPermissionRequests(
95+
[
96+
sdkSession("run-before"),
97+
runStarted("run-before"),
98+
request("r1", "t1"),
99+
sdkSession("run-after"),
100+
runStarted("run-after"),
101+
prompt(
102+
"This is the user's selected answer to the AskUserQuestion prompt that was pending before this cloud run resumed.",
103+
),
104+
],
105+
{ taskRunId: "run-after" },
106+
);
107+
108+
expect(pending).toEqual([]);
109+
});
110+
111+
it("keeps current-run questions when scoped derivation matches the run", () => {
112+
const pending = derivePendingPermissionRequests(
113+
[sdkSession("run-1"), runStarted("run-1"), request("r1", "t1")],
114+
{ taskRunId: "run-1" },
115+
);
116+
117+
expect(pending.map((p) => p.requestId)).toEqual(["r1"]);
118+
});
68119
});
69120

70121
describe("isPermissionRequestAlreadySurfaced", () => {

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

Lines changed: 64 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,66 @@ 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("MIT");
106+
});
107+
108+
it("carries every entry of a multi-question answers map", () => {
109+
const prompt = formatPermissionAnswerPrompt(
110+
questionPermission([{ question: "Q1?" }, { question: "Q2?" }]),
111+
"option_0",
112+
undefined,
113+
{ "Q1?": "A1", "Q2?": "A2" },
114+
);
115+
expect(prompt).toBe("1. A1\n2. A2");
116+
});
117+
118+
it("falls back to the picked option label when no answers map is sent", () => {
119+
const prompt = formatPermissionAnswerPrompt(
120+
questionPermission([{ question: "Which license should I use?" }]),
121+
"option_1",
122+
);
123+
expect(prompt).toBe("Apache 2.0");
124+
});
125+
126+
it("uses free-text custom input for a question", () => {
127+
const prompt = formatPermissionAnswerPrompt(
128+
questionPermission([{ question: "Which license should I use?" }]),
129+
"_other",
130+
"BSD, actually",
131+
);
132+
expect(prompt).toBe("BSD, actually");
133+
});
134+
135+
it("returns null for a plain approval so no resume run is spun", () => {
136+
const approval = makePermission([
137+
{ optionId: "allow", kind: "allow_once" },
138+
]);
139+
expect(formatPermissionAnswerPrompt(approval, "allow")).toBeNull();
140+
expect(
141+
formatPermissionAnswerPrompt(approval, "reject", "not like this"),
142+
).toBeNull();
143+
});
144+
});

packages/core/src/sessions/permissionResponse.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,76 @@ 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+
/**
78+
* Builds the follow-up prompt that carries a question's selected answer into a
79+
* resumed run. Once a cloud run has terminalized, its sandbox and the pending
80+
* permission promise inside it are gone, so a permission_response command has
81+
* nothing left to resolve. The answer travels as a fresh user message on a
82+
* resume run instead.
83+
* Returns null when there is nothing meaningful to carry forward (a plain
84+
* approval), so callers can drop the response rather than spin a pointless run.
85+
*/
86+
export function formatPermissionAnswerPrompt(
87+
permission: PermissionRequest | undefined,
88+
optionId: string,
89+
customInput?: string,
90+
answers?: Record<string, string>,
91+
): string | null {
92+
const selectedAnswers: string[] = [];
93+
for (const [question, answer] of Object.entries(answers ?? {})) {
94+
if (question.trim() && answer.trim()) {
95+
selectedAnswers.push(answer.trim());
96+
}
97+
}
98+
99+
if (selectedAnswers.length === 0) {
100+
if (!isQuestionPermission(permission)) {
101+
return null;
102+
}
103+
// A question answered without an answers map: free text, or a bare option pick.
104+
const answerText =
105+
customInput?.trim() ||
106+
permission?.options
107+
.find((option) => option.optionId === optionId)
108+
?.name?.trim();
109+
if (!answerText) {
110+
return null;
111+
}
112+
selectedAnswers.push(answerText);
113+
} else {
114+
const extraInput = customInput?.trim();
115+
if (extraInput && !selectedAnswers.includes(extraInput)) {
116+
selectedAnswers.push(extraInput);
117+
}
118+
}
119+
120+
if (selectedAnswers.length === 1) {
121+
return selectedAnswers[0] ?? null;
122+
}
123+
124+
return selectedAnswers
125+
.map((answer, index) => `${index + 1}. ${answer}`)
126+
.join("\n");
127+
}

0 commit comments

Comments
 (0)