Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/core/src/sessions/derivePendingPermissionRequests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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", () => {
Expand Down
69 changes: 69 additions & 0 deletions packages/core/src/sessions/permissionResponse.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PermissionRequest } from "@posthog/shared";
import { describe, expect, it } from "vitest";
import {
formatPermissionAnswerPrompt,
isOtherPermissionOption,
planPermissionResponse,
} from "./permissionResponse";
Expand Down Expand Up @@ -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();
},
);
});
73 changes: 73 additions & 0 deletions packages/core/src/sessions/permissionResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>,
): 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");
}
Loading
Loading