Skip to content

Commit 7ece0f0

Browse files
authored
fix(cloud-task): resume terminal cloud runs with the selected question answer (#3215)
1 parent ff4a733 commit 7ece0f0

5 files changed

Lines changed: 704 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: 69 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,71 @@ 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.each([
136+
["plain approval", "allow", undefined],
137+
["plain rejection with feedback", "reject", "not like this"],
138+
])(
139+
"returns null for %s so no resume run is spun",
140+
(_caseName, optionId, customInput) => {
141+
const approval = makePermission([
142+
{ optionId: "allow", kind: "allow_once" },
143+
]);
144+
expect(
145+
formatPermissionAnswerPrompt(approval, optionId, customInput),
146+
).toBeNull();
147+
},
148+
);
149+
});

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)