mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
fix(agents): keep rejected plans collapsed in conversation order (#4756)
Keep plan proposals at their original timeline position through approval, rejection, and cancellation. Make plan cards collapsible while retaining readable history after follow-up prompts and daemon restart.
This commit is contained in:
@@ -88,6 +88,11 @@ The installed tail carries `hasOlder`, so history skipped by a replacement remai
|
||||
ordinary backward pagination. A backward page is accepted only when it is adjacent to the current
|
||||
history start; a response requested from a pre-replacement range is stale and is discarded.
|
||||
|
||||
A plan approval keeps the original proposal's tool-call identity through resolution and provider
|
||||
history replay. The pending approval UI can hide that tool from presentation, but the client model
|
||||
must retain its position. Creating a new history card on rejection places it after the prompt that
|
||||
rejected it; changing steer-event ordering would also put new assistant output before that prompt.
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
The session projection remains host-scoped for as long as the host is registered. The viewed-timeline
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
@@ -18,7 +19,7 @@ const scenarios = [
|
||||
},
|
||||
{
|
||||
provider: "codex" as const,
|
||||
providerConfig: { featureValues: { plan_mode: true } },
|
||||
providerConfig: { model: "gpt-5.6-sol", featureValues: { plan_mode: true } },
|
||||
planPrompt:
|
||||
"Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.",
|
||||
planText: "Inspect permission steering",
|
||||
@@ -27,57 +28,151 @@ const scenarios = [
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("composer steer supersedes plan approval", () => {
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
|
||||
async function readConversationOrder(page: Page, prompt: string, reply: string) {
|
||||
const plan = page.getByTestId("timeline-plan-card");
|
||||
const question = page.getByTestId("user-message").filter({ hasText: prompt });
|
||||
const answer = page.getByTestId("assistant-message").filter({ hasText: reply });
|
||||
const boxes = await Promise.all([
|
||||
plan.boundingBox(),
|
||||
question.boundingBox(),
|
||||
answer.boundingBox(),
|
||||
]);
|
||||
return boxes
|
||||
.map((box, index) => {
|
||||
if (!box) throw new Error("Expected the plan, follow-up, and answer to be rendered");
|
||||
return { role: ["plan", "question", "answer"][index], y: box.y };
|
||||
})
|
||||
.sort((a, b) => a.y - b.y)
|
||||
.map((item) => item.role);
|
||||
}
|
||||
|
||||
async function togglePlan(
|
||||
page: Page,
|
||||
testId: string,
|
||||
name: string,
|
||||
expanded: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = page.getByTestId(testId).getByRole("button", { name, exact: true });
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(expanded));
|
||||
}
|
||||
|
||||
async function expectRejectedPlanCollapsed(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("timeline-plan-card")
|
||||
.getByRole("button", { name: "Rejected plan", exact: true }),
|
||||
).toHaveAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
async function reopenConversation(page: Page, reply: string): Promise<void> {
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("timeline-plan-card")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("assistant-message").filter({ hasText: reply })).toBeVisible();
|
||||
}
|
||||
|
||||
type PlanScenario = (typeof scenarios)[number];
|
||||
interface PlanSteer {
|
||||
page: Page;
|
||||
scenario: PlanScenario;
|
||||
testInfo: TestInfo;
|
||||
}
|
||||
|
||||
async function withPlanSteer(context: PlanSteer, journey: () => Promise<void>): Promise<void> {
|
||||
const { page, scenario } = context;
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(420_000);
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await submitMessage(page, scenario.planPrompt);
|
||||
await waitForPermissionPrompt(page, 180_000);
|
||||
|
||||
const pendingPlan = page.getByTestId("permission-plan-card");
|
||||
await expect(pendingPlan).toContainText(scenario.planText);
|
||||
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
|
||||
await page.screenshot({ path: pendingScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} pending plan`, {
|
||||
path: pendingScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await submitMessage(page, scenario.steerPrompt);
|
||||
await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 });
|
||||
const rejectedPlan = page.getByTestId("timeline-plan-card");
|
||||
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
|
||||
await expect(rejectedPlan).toContainText(scenario.planText);
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
|
||||
).toBeVisible({
|
||||
timeout: 180_000,
|
||||
});
|
||||
|
||||
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
|
||||
await page.screenshot({ path: rejectedScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} rejected plan`, {
|
||||
path: rejectedScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await journey();
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function reviewCollapsibleProposal({ page, scenario, testInfo }: PlanSteer): Promise<void> {
|
||||
await submitMessage(page, scenario.planPrompt);
|
||||
await waitForPermissionPrompt(page, 180_000);
|
||||
|
||||
const pendingPlan = page.getByTestId("permission-plan-card");
|
||||
await expect(pendingPlan).toContainText(scenario.planText);
|
||||
await togglePlan(page, "permission-plan-card", "Plan", false);
|
||||
await expect(page.getByTestId("permission-request-deny")).toBeVisible();
|
||||
await togglePlan(page, "permission-plan-card", "Plan", true);
|
||||
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
|
||||
await page.screenshot({ path: pendingScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} pending plan`, {
|
||||
path: pendingScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
}
|
||||
|
||||
async function rejectWithFollowUp({ page, scenario, testInfo }: PlanSteer): Promise<void> {
|
||||
await submitMessage(page, scenario.steerPrompt);
|
||||
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, { timeout: 30_000 });
|
||||
const rejectedPlan = page.getByTestId("timeline-plan-card");
|
||||
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
|
||||
await expectRejectedPlanCollapsed(page);
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
|
||||
).toBeVisible({
|
||||
timeout: 180_000,
|
||||
});
|
||||
|
||||
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
|
||||
await page.screenshot({ path: rejectedScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} rejected plan`, {
|
||||
path: rejectedScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("user-message").filter({ hasText: scenario.steerPrompt }),
|
||||
).toBeVisible();
|
||||
expect
|
||||
.soft(await readConversationOrder(page, scenario.steerPrompt, scenario.reply))
|
||||
.toEqual(["plan", "question", "answer"]);
|
||||
}
|
||||
|
||||
async function rereadRejectedPlan({ page, scenario, testInfo }: PlanSteer): Promise<void> {
|
||||
const rejectedPlan = page.getByTestId("timeline-plan-card");
|
||||
await togglePlan(page, "timeline-plan-card", "Rejected plan", true);
|
||||
await expect(rejectedPlan).toContainText(scenario.planText);
|
||||
await rejectedPlan.screenshot({
|
||||
path: testInfo.outputPath(`${scenario.provider}-expanded-rejected-plan.png`),
|
||||
});
|
||||
await togglePlan(page, "timeline-plan-card", "Rejected plan", false);
|
||||
}
|
||||
|
||||
async function restoreConversationOrder({ page, scenario }: PlanSteer): Promise<void> {
|
||||
await reopenConversation(page, scenario.reply);
|
||||
await expectRejectedPlanCollapsed(page);
|
||||
expect(await readConversationOrder(page, scenario.steerPrompt, scenario.reply)).toEqual([
|
||||
"plan",
|
||||
"question",
|
||||
"answer",
|
||||
]);
|
||||
}
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(420_000);
|
||||
const context = { page, scenario, testInfo };
|
||||
await withPlanSteer(context, async () => {
|
||||
await test.step("Review a collapsible proposal", () => reviewCollapsibleProposal(context));
|
||||
await test.step("Reject with a follow-up below the plan", () => rejectWithFollowUp(context));
|
||||
await test.step("Reopen the rejected plan", () => rereadRejectedPlan(context));
|
||||
await test.step("Keep conversation order after reload", () =>
|
||||
restoreConversationOrder(context));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { mkdtempSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import {
|
||||
allowPermission,
|
||||
denyPermission,
|
||||
waitForPermissionPrompt,
|
||||
} from "../support/helpers/permissions";
|
||||
import {
|
||||
assertComposerIdle,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
type AgentHandle,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
import { connectDaemonClient } from "../support/helpers/daemon-client-loader";
|
||||
import { getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
|
||||
const PLAN_PROMPT =
|
||||
"Propose a concise plan to append one line Hello to README.md. Present it for approval using ExitPlanMode. Do not ask questions. Only implement after approval. If the plan is rejected, reply PLAN_REJECTED and stop without proposing another plan. After implementing an approved plan, reply PLAN_IMPLEMENTED.";
|
||||
|
||||
async function requestPlan(page: Page, prompt: string): Promise<void> {
|
||||
await submitMessage(page, prompt);
|
||||
await waitForPermissionPrompt(page, 180_000);
|
||||
await expect(page.getByTestId("permission-plan-card")).toContainText("README.md");
|
||||
}
|
||||
|
||||
function planWithOutcome(page: Page, label: string) {
|
||||
return page
|
||||
.getByTestId("timeline-plan-card")
|
||||
.filter({ has: page.getByRole("button", { name: label, exact: true }) });
|
||||
}
|
||||
|
||||
async function expectPlanOutcome(page: Page, label: string, expanded: boolean): Promise<void> {
|
||||
const card = planWithOutcome(page, label);
|
||||
await expect(card).toHaveCount(1);
|
||||
await expect(card.getByRole("button", { name: label, exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
String(expanded),
|
||||
);
|
||||
}
|
||||
|
||||
async function toggleRecordedPlan(page: Page, label: string, expanded: boolean): Promise<void> {
|
||||
await planWithOutcome(page, label).getByRole("button", { name: label, exact: true }).click();
|
||||
await expectPlanOutcome(page, label, expanded);
|
||||
}
|
||||
|
||||
async function expectClaudeReply(page: Page, marker: string): Promise<void> {
|
||||
await expect(page.getByTestId("assistant-message").filter({ hasText: marker })).toBeVisible({
|
||||
timeout: 180_000,
|
||||
});
|
||||
await assertComposerIdle({ page });
|
||||
}
|
||||
|
||||
async function restartIsolatedDaemon(): Promise<void> {
|
||||
const port = Number(getE2EDaemonPort());
|
||||
expect([6767, 6768]).not.toContain(port);
|
||||
const client = await connectDaemonClient<{
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
getDaemonStatus(): Promise<{ pid: number }>;
|
||||
restartServer(reason: string): Promise<unknown>;
|
||||
}>({ port, clientIdPrefix: "plan-history-restart" });
|
||||
try {
|
||||
const before = (await client.getDaemonStatus()).pid;
|
||||
await client.restartServer("Verify plan history replay in the isolated Playwright daemon");
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
return (await client.getDaemonStatus()).pid;
|
||||
} catch {
|
||||
return before;
|
||||
}
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.not.toBe(before);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function expectRecordedPlans(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(2, { timeout: 60_000 });
|
||||
await expectPlanOutcome(page, "Rejected plan", false);
|
||||
await expectPlanOutcome(page, "Approved plan", true);
|
||||
}
|
||||
|
||||
interface PlanReview {
|
||||
page: Page;
|
||||
cwd: string;
|
||||
testInfo: TestInfo;
|
||||
}
|
||||
|
||||
async function withPlanReview(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
review: (context: PlanReview) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-plan-lifecycle-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "haiku", modeId: "plan" },
|
||||
});
|
||||
await review({ page, cwd, testInfo });
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectPlanWithoutImplementing({ page, cwd, testInfo }: PlanReview): Promise<void> {
|
||||
await requestPlan(page, PLAN_PROMPT);
|
||||
await denyPermission(page);
|
||||
await expectClaudeReply(page, "PLAN_REJECTED");
|
||||
await expectPlanOutcome(page, "Rejected plan", false);
|
||||
await toggleRecordedPlan(page, "Rejected plan", true);
|
||||
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
|
||||
await page.screenshot({ path: testInfo.outputPath("button-rejected-plan-expanded.png") });
|
||||
await toggleRecordedPlan(page, "Rejected plan", false);
|
||||
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).not.toContain("Hello");
|
||||
}
|
||||
|
||||
async function approveFreshPlanAndImplement({ page, cwd, testInfo }: PlanReview): Promise<void> {
|
||||
await requestPlan(page, `Propose the plan again for a fresh approval. ${PLAN_PROMPT}`);
|
||||
await allowPermission(page);
|
||||
await expectClaudeReply(page, "PLAN_IMPLEMENTED");
|
||||
expect(readFileSync(path.join(cwd, "README.md"), "utf8")).toContain("Hello");
|
||||
await expectRecordedPlans(page);
|
||||
await toggleRecordedPlan(page, "Approved plan", false);
|
||||
await toggleRecordedPlan(page, "Approved plan", true);
|
||||
await page.screenshot({ path: testInfo.outputPath("approved-plan.png") });
|
||||
}
|
||||
|
||||
async function restoreReadablePlanHistory({ page, testInfo }: PlanReview): Promise<void> {
|
||||
await restartIsolatedDaemon();
|
||||
await page.reload();
|
||||
await expectRecordedPlans(page);
|
||||
await toggleRecordedPlan(page, "Rejected plan", true);
|
||||
await expect(planWithOutcome(page, "Rejected plan")).toContainText("README.md");
|
||||
await toggleRecordedPlan(page, "Rejected plan", false);
|
||||
await page.screenshot({ path: testInfo.outputPath("restored-plans.png") });
|
||||
}
|
||||
|
||||
test("Claude button rejection and approval preserve collapsible plans after daemon restart", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(420_000);
|
||||
await withPlanReview(page, testInfo, async (review) => {
|
||||
await test.step("Reject a plan without implementing it", () =>
|
||||
rejectPlanWithoutImplementing(review));
|
||||
await test.step("Approve and implement a fresh plan", () =>
|
||||
approveFreshPlanAndImplement(review));
|
||||
await test.step("Restore readable plans after daemon restart", () =>
|
||||
restoreReadablePlanHistory(review));
|
||||
});
|
||||
});
|
||||
@@ -1565,6 +1565,7 @@ function PermissionRequestCard({
|
||||
title={title}
|
||||
description={description}
|
||||
text={planMarkdown}
|
||||
outcome="pending"
|
||||
footer={footer}
|
||||
testID="permission-plan-card"
|
||||
disableOuterSpacing
|
||||
|
||||
@@ -3167,6 +3167,7 @@ export const ToolCall = memo(function ToolCall({
|
||||
return (
|
||||
<PlanCard
|
||||
text={effectiveDetail.text}
|
||||
outcome={presentation.planOutcome}
|
||||
testID="timeline-plan-card"
|
||||
disableOuterSpacing={disableOuterSpacing}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import Markdown, { type ASTNode } from "react-native-markdown-display";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type StyleProp,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { type ASTNode } from "react-native-markdown-display";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { ChevronRight } from "lucide-react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { createMarkdownParser } from "@/utils/markdown-parser";
|
||||
|
||||
@@ -180,54 +190,81 @@ function createPlanMarkdownRules() {
|
||||
};
|
||||
}
|
||||
|
||||
export function PlanCard({
|
||||
title,
|
||||
description,
|
||||
text,
|
||||
footer,
|
||||
disableOuterSpacing = false,
|
||||
testID,
|
||||
}: {
|
||||
export type PlanOutcome = "pending" | "approved" | "rejected" | "canceled";
|
||||
|
||||
interface PlanCardProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
text: string;
|
||||
outcome?: PlanOutcome;
|
||||
footer?: ReactNode;
|
||||
disableOuterSpacing?: boolean;
|
||||
testID?: string;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const markdownStyles = createMarkdownStyles(theme);
|
||||
const markdownRules = createPlanMarkdownRules();
|
||||
const resolvedTitle = title ?? t("agentStream.permission.plan");
|
||||
}
|
||||
|
||||
const ThemedChevron = withUnistyles(ChevronRight);
|
||||
const chevronColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const markdownRules = createPlanMarkdownRules();
|
||||
|
||||
export function PlanCard(props: PlanCardProps) {
|
||||
// A resolution starts its own presentation state; subsequent taps stay local.
|
||||
return <PlanCardContent key={props.outcome ?? "proposed"} {...props} />;
|
||||
}
|
||||
|
||||
function PlanCardContent({
|
||||
title,
|
||||
description,
|
||||
text,
|
||||
outcome,
|
||||
footer,
|
||||
disableOuterSpacing = false,
|
||||
testID,
|
||||
}: PlanCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(outcome !== "rejected" && outcome !== "canceled");
|
||||
const labels = {
|
||||
pending: title ?? t("agentStream.permission.plan"),
|
||||
rejected: t("agentStream.permission.rejectedPlan"),
|
||||
approved: t("agentStream.permission.approvedPlan"),
|
||||
canceled: t("agentStream.permission.canceledPlan"),
|
||||
};
|
||||
const resolvedTitle = labels[outcome ?? "pending"];
|
||||
const accessibilityState = useMemo(() => ({ expanded }), [expanded]);
|
||||
const webExpandedState = useMemo(
|
||||
() => (isWeb ? ({ "aria-expanded": expanded } as const) : null),
|
||||
[expanded],
|
||||
);
|
||||
const toggleExpanded = useCallback(() => setExpanded((value) => !value), []);
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
styles.container,
|
||||
disableOuterSpacing && styles.containerCompact,
|
||||
{
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
],
|
||||
[disableOuterSpacing, theme.colors.surface1, theme.colors.border],
|
||||
() => [styles.container, disableOuterSpacing && styles.containerCompact],
|
||||
[disableOuterSpacing],
|
||||
);
|
||||
const titleStyle = useMemo(
|
||||
() => [styles.title, { color: theme.colors.foreground }],
|
||||
[theme.colors.foreground],
|
||||
);
|
||||
const descriptionStyle = useMemo(
|
||||
() => [styles.description, { color: theme.colors.foregroundMuted }],
|
||||
[theme.colors.foregroundMuted],
|
||||
const chevronStyle = useMemo(
|
||||
() => [styles.chevron, expanded && styles.chevronExpanded],
|
||||
[expanded],
|
||||
);
|
||||
|
||||
return (
|
||||
<View testID={testID} style={containerStyle}>
|
||||
<Text style={titleStyle}>{resolvedTitle}</Text>
|
||||
{description ? <Text style={descriptionStyle}>{description}</Text> : null}
|
||||
<Markdown style={markdownStyles} rules={markdownRules} markdownit={planMarkdownParser}>
|
||||
{text}
|
||||
</Markdown>
|
||||
<Pressable
|
||||
{...webExpandedState}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={resolvedTitle}
|
||||
accessibilityState={accessibilityState}
|
||||
onPress={toggleExpanded}
|
||||
style={styles.header}
|
||||
>
|
||||
<View style={chevronStyle}>
|
||||
<ThemedChevron size={16} uniProps={chevronColor} />
|
||||
</View>
|
||||
<Text style={styles.title}>{resolvedTitle}</Text>
|
||||
</Pressable>
|
||||
{expanded ? (
|
||||
<View style={styles.body}>
|
||||
{description ? <Text style={styles.description}>{description}</Text> : null}
|
||||
<MarkdownRenderer text={text} rules={markdownRules} markdownit={planMarkdownParser} />
|
||||
</View>
|
||||
) : null}
|
||||
{footer ? <View style={styles.footer}>{footer}</View> : null}
|
||||
</View>
|
||||
);
|
||||
@@ -239,16 +276,30 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[3],
|
||||
borderRadius: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderColor: theme.colors.border,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
containerCompact: {
|
||||
marginVertical: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 24,
|
||||
},
|
||||
chevron: {},
|
||||
chevronExpanded: { transform: [{ rotate: "90deg" }] },
|
||||
body: { gap: theme.spacing[2] },
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 22,
|
||||
},
|
||||
description: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
|
||||
@@ -209,6 +209,10 @@ export const ar: TranslationResources = {
|
||||
historyLoadFailed: "تعذر تحميل سجل الوكيل",
|
||||
messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).",
|
||||
permission: {
|
||||
rejectedPlan: "خطة مرفوضة",
|
||||
approvedPlan: "خطة معتمدة",
|
||||
canceledPlan: "خطة ملغاة",
|
||||
|
||||
plan: "يخطط",
|
||||
required: "الإذن مطلوب",
|
||||
deny: "ينكر",
|
||||
|
||||
@@ -206,6 +206,10 @@ export const en = {
|
||||
historyLoadFailed: "Couldn't load agent history",
|
||||
messageCapped: "This message was capped ({{bytes}} bytes).",
|
||||
permission: {
|
||||
rejectedPlan: "Rejected plan",
|
||||
approvedPlan: "Approved plan",
|
||||
canceledPlan: "Canceled plan",
|
||||
|
||||
plan: "Plan",
|
||||
required: "Permission Required",
|
||||
deny: "Deny",
|
||||
|
||||
@@ -209,6 +209,10 @@ export const es: TranslationResources = {
|
||||
historyLoadFailed: "No se pudo cargar el historial del agente",
|
||||
messageCapped: "Este mensaje fue truncado ({{bytes}} bytes).",
|
||||
permission: {
|
||||
rejectedPlan: "Plan rechazado",
|
||||
approvedPlan: "Plan aprobado",
|
||||
canceledPlan: "Plan cancelado",
|
||||
|
||||
plan: "Plan",
|
||||
required: "Permiso requerido",
|
||||
deny: "Denegar",
|
||||
|
||||
@@ -211,6 +211,10 @@ export const fr: TranslationResources = {
|
||||
historyLoadFailed: "Impossible de charger l’historique de l’agent",
|
||||
messageCapped: "Ce message a été tronqué ({{bytes}} octets).",
|
||||
permission: {
|
||||
rejectedPlan: "Plan refusé",
|
||||
approvedPlan: "Plan approuvé",
|
||||
canceledPlan: "Plan annulé",
|
||||
|
||||
plan: "Plan",
|
||||
required: "Autorisation requise",
|
||||
deny: "Refuser",
|
||||
|
||||
@@ -209,6 +209,10 @@ export const ja: TranslationResources = {
|
||||
historyLoadFailed: "エージェントの履歴を読み込めませんでした",
|
||||
messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。",
|
||||
permission: {
|
||||
rejectedPlan: "却下されたプラン",
|
||||
approvedPlan: "承認されたプラン",
|
||||
canceledPlan: "キャンセルされたプラン",
|
||||
|
||||
plan: "プラン",
|
||||
required: "権限が必要です",
|
||||
deny: "拒否",
|
||||
|
||||
@@ -209,6 +209,10 @@ export const ko: TranslationResources = {
|
||||
historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.",
|
||||
messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).",
|
||||
permission: {
|
||||
rejectedPlan: "거부된 계획",
|
||||
approvedPlan: "승인된 계획",
|
||||
canceledPlan: "취소된 계획",
|
||||
|
||||
plan: "계획",
|
||||
required: "권한 필요",
|
||||
deny: "거부",
|
||||
|
||||
@@ -209,6 +209,10 @@ export const ptBR: TranslationResources = {
|
||||
historyLoadFailed: "Não foi possível carregar o histórico do agente",
|
||||
messageCapped: "Esta mensagem foi truncada ({{bytes}} bytes).",
|
||||
permission: {
|
||||
rejectedPlan: "Plano rejeitado",
|
||||
approvedPlan: "Plano aprovado",
|
||||
canceledPlan: "Plano cancelado",
|
||||
|
||||
plan: "Plano",
|
||||
required: "Permissão necessária",
|
||||
deny: "Negar",
|
||||
|
||||
@@ -209,6 +209,10 @@ export const ru: TranslationResources = {
|
||||
historyLoadFailed: "Не удалось загрузить историю агента",
|
||||
messageCapped: "Это сообщение было обрезано ({{bytes}} байт).",
|
||||
permission: {
|
||||
rejectedPlan: "Отклонённый план",
|
||||
approvedPlan: "Одобренный план",
|
||||
canceledPlan: "Отменённый план",
|
||||
|
||||
plan: "План",
|
||||
required: "Требуется разрешение",
|
||||
deny: "Отклонить",
|
||||
|
||||
@@ -209,6 +209,10 @@ export const zhCN: TranslationResources = {
|
||||
historyLoadFailed: "无法加载智能体历史记录",
|
||||
messageCapped: "此消息已被截断({{bytes}} 字节)。",
|
||||
permission: {
|
||||
rejectedPlan: "已拒绝的计划",
|
||||
approvedPlan: "已批准的计划",
|
||||
canceledPlan: "已取消的计划",
|
||||
|
||||
plan: "Plan",
|
||||
required: "需要权限",
|
||||
deny: "拒绝",
|
||||
|
||||
@@ -63,6 +63,28 @@ function project(input: {
|
||||
}
|
||||
|
||||
describe("tool call detail-level projection", () => {
|
||||
it.each(["detailed", "overview"] as const)(
|
||||
"keeps pending approval tools out of %s presentation without removing their canonical position",
|
||||
(level) => {
|
||||
const pending = toolCall(
|
||||
"1",
|
||||
{ type: "plan", text: "Ship it" },
|
||||
{ name: "ExitPlanMode", status: "running" },
|
||||
);
|
||||
const followUp = {
|
||||
kind: "user_message",
|
||||
id: "question",
|
||||
text: "What about tests?",
|
||||
timestamp: new Date(2),
|
||||
} satisfies StreamItem;
|
||||
const tail = [pending, followUp];
|
||||
expect(project({ level, tail }).tail).toEqual([followUp]);
|
||||
expect(tail).toEqual([pending, followUp]);
|
||||
const rejected = toolCall("1", { type: "plan", text: "Ship it" }, { name: "plan_approval" });
|
||||
expect(project({ level, tail: [rejected, followUp] }).tail).toEqual([rejected, followUp]);
|
||||
},
|
||||
);
|
||||
|
||||
it("passes detailed timelines through without grouping work", () => {
|
||||
const tail = [toolCall("1", { type: "shell", command: "one" })];
|
||||
const head = [toolCall("2", { type: "shell", command: "two" })];
|
||||
|
||||
@@ -20,6 +20,24 @@ export interface ToolCallDetailProjection extends GroupedToolCalls<ToolCallDetai
|
||||
|
||||
const EMPTY_TOOL_CALL_GROUPS = new Map<string, ToolCallDetailGroup>();
|
||||
|
||||
// Approval UI owns pending plan presentation. Retain the canonical tool in the
|
||||
// stream model so resolving it can reveal a card at its original position.
|
||||
const visibleItemsCache = new WeakMap<StreamItem[], StreamItem[]>();
|
||||
function visibleToolCallItems(items: StreamItem[]): StreamItem[] {
|
||||
const cached = visibleItemsCache.get(items);
|
||||
if (cached) return cached;
|
||||
const visible = items.filter((item) => {
|
||||
if (item.kind !== "tool_call" || item.payload.source !== "agent") return true;
|
||||
const data = item.payload.data;
|
||||
return (
|
||||
data.name !== "ExitPlanMode" && !(data.name === "plan_approval" && data.status === "running")
|
||||
);
|
||||
});
|
||||
const result = visible.length === items.length ? items : visible;
|
||||
visibleItemsCache.set(items, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function prepareToolCallHistory(
|
||||
level: ToolCallDetailLevel,
|
||||
tail: StreamItem[],
|
||||
@@ -29,7 +47,10 @@ export function prepareToolCallHistory(
|
||||
}
|
||||
return {
|
||||
mode: "overview",
|
||||
grouped: prepareGroupedHistory({ tail, buildGroup: buildOverviewGroup }),
|
||||
grouped: prepareGroupedHistory({
|
||||
tail: visibleToolCallItems(tail),
|
||||
buildGroup: buildOverviewGroup,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,8 +63,8 @@ export function projectToolCallDetailLevel(input: {
|
||||
}): ToolCallDetailProjection {
|
||||
if (input.level === "detailed") {
|
||||
return {
|
||||
tail: input.tail,
|
||||
head: input.head,
|
||||
tail: visibleToolCallItems(input.tail),
|
||||
head: visibleToolCallItems(input.head),
|
||||
groupsByHostId: EMPTY_TOOL_CALL_GROUPS,
|
||||
historyGroupUpdatesByHostId: EMPTY_TOOL_CALL_GROUPS,
|
||||
};
|
||||
@@ -53,7 +74,7 @@ export function projectToolCallDetailLevel(input: {
|
||||
}
|
||||
return groupLiveToolCalls({
|
||||
history: input.preparedHistory.grouped,
|
||||
head: input.head,
|
||||
head: visibleToolCallItems(input.head),
|
||||
isTurnActive: input.isTurnActive,
|
||||
buildGroup: buildOverviewGroup,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PlanOutcome } from "@/components/plan-card";
|
||||
import type { ComponentType } from "react";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import type { ToolCallDisplayInput } from "@/utils/tool-call-display";
|
||||
@@ -31,6 +32,7 @@ export interface ToolCallPresentation {
|
||||
canOpenDetails: boolean;
|
||||
openFilePath: string | null;
|
||||
isPlan: boolean;
|
||||
planOutcome?: PlanOutcome;
|
||||
}
|
||||
|
||||
export type ToolCallIconResolver = (
|
||||
@@ -75,5 +77,14 @@ export function buildToolCallPresentation(
|
||||
canOpenDetails: hasDetails || isLoadingDetails,
|
||||
openFilePath: extractToolCallFilePath(input.detail),
|
||||
isPlan: input.detail?.type === "plan",
|
||||
planOutcome: input.detail?.type === "plan" ? resolvePlanOutcome(input) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePlanOutcome(input: BuildToolCallPresentationInput): PlanOutcome | undefined {
|
||||
if (input.status === "canceled") return "canceled";
|
||||
if (input.metadata?.approved === false) return "rejected";
|
||||
if (input.metadata?.approved === true) return "approved";
|
||||
if (input.status === "running" || input.status === "executing") return "pending";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,51 @@ import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
|
||||
type CanonicalToolStatus = "running" | "completed" | "failed" | "canceled";
|
||||
|
||||
it("updates a resolved Claude plan at its proposal position across a follow-up", () => {
|
||||
const proposal = {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
turnId: "turn-1",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "plan-1",
|
||||
name: "ExitPlanMode",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "plan", text: "Ship it" },
|
||||
},
|
||||
} satisfies AgentStreamEventPayload;
|
||||
const pending = reduceStreamUpdate([], proposal, new Date(1));
|
||||
const steered = reduceStreamUpdate(
|
||||
pending,
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
turnId: "turn-1",
|
||||
item: { type: "user_message", text: "What about tests?", messageId: "question" },
|
||||
},
|
||||
new Date(2),
|
||||
);
|
||||
const resolved = reduceStreamUpdate(
|
||||
steered,
|
||||
{
|
||||
...proposal,
|
||||
item: {
|
||||
...proposal.item,
|
||||
name: "plan_approval",
|
||||
status: "completed",
|
||||
metadata: { approved: false },
|
||||
},
|
||||
},
|
||||
new Date(3),
|
||||
);
|
||||
expect(resolved.map((item) => item.kind)).toEqual(["tool_call", "user_message"]);
|
||||
expect(resolved[0]?.id).toBe(pending[0]?.id);
|
||||
expect(resolved[0]).toMatchObject({
|
||||
payload: { data: { name: "plan_approval", metadata: { approved: false } } },
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin timeline rows", () => {
|
||||
it("uses the protocol identity format for stream tool and plugin rows", () => {
|
||||
const tool = {
|
||||
|
||||
@@ -1399,10 +1399,6 @@ function reduceTimelineToolCall(
|
||||
.trim()
|
||||
.replace(/[.\s-]+/g, "_")
|
||||
.toLowerCase();
|
||||
if (event.provider === "claude" && normalizedToolName === "exitplanmode") {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (
|
||||
event.provider === "claude" &&
|
||||
(normalizedToolName === "todowrite" || normalizedToolName === "todo_write")
|
||||
|
||||
@@ -2515,9 +2515,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
/**
|
||||
* A denied request is the only record the transcript gets. Plans especially:
|
||||
* the pending card is the only place the plan text lives, so losing it means
|
||||
* the user can no longer read what they just declined.
|
||||
* Settle the original call immediately, before waiting for the SDK tool result.
|
||||
* Plan resolution must use the native call ID so it cannot move past a follow-up.
|
||||
*/
|
||||
private recordDeniedPermissionTimeline(
|
||||
request: AgentPermissionRequest,
|
||||
@@ -2538,28 +2537,24 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
if (request.kind === "plan") {
|
||||
let planText: string | null = null;
|
||||
if (typeof request.metadata?.planText === "string") {
|
||||
planText = request.metadata.planText;
|
||||
} else if (typeof request.input?.plan === "string") {
|
||||
planText = request.input.plan;
|
||||
}
|
||||
if (!planText) return;
|
||||
this.pushToolCall({
|
||||
type: "tool_call",
|
||||
name: "plan_approval",
|
||||
callId: request.id,
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plan", text: planText },
|
||||
metadata: {
|
||||
approved: false,
|
||||
actionId: response.selectedActionId ?? "reject",
|
||||
},
|
||||
});
|
||||
this.pushToolCall(
|
||||
mapClaudeFailedToolCall({
|
||||
name: "ExitPlanMode",
|
||||
callId: this.planToolCallId(request),
|
||||
input: request.input,
|
||||
error: response.message ?? "Permission denied",
|
||||
metadata: { actionId: response.selectedActionId ?? "reject" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private planToolCallId(request: AgentPermissionRequest): string {
|
||||
return typeof request.metadata?.toolUseId === "string"
|
||||
? request.metadata.toolUseId
|
||||
: request.id;
|
||||
}
|
||||
|
||||
private resolveDeniedPermission(
|
||||
request: AgentPermissionRequest,
|
||||
response: Extract<AgentPermissionResponse, { behavior: "deny" }>,
|
||||
@@ -2610,8 +2605,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await this.setMode(targetMode);
|
||||
this.pushToolCall(
|
||||
mapClaudeCompletedToolCall({
|
||||
name: "plan_approval",
|
||||
callId: pending.request.id,
|
||||
name: "ExitPlanMode",
|
||||
callId: this.planToolCallId(pending.request),
|
||||
input: pending.request.input ?? null,
|
||||
output: {
|
||||
approved: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
mapClaudeCanceledToolCall,
|
||||
mapClaudeCompletedToolCall,
|
||||
mapClaudeFailedToolCall,
|
||||
mapClaudeRunningToolCall,
|
||||
@@ -15,6 +16,40 @@ function expectMapped<T>(item: T | null): T {
|
||||
}
|
||||
|
||||
describe("claude tool-call mapper", () => {
|
||||
it("preserves a plan's original tool identity and outcome for live and replayed results", () => {
|
||||
const proposal = { name: "ExitPlanMode", callId: "plan-tool-1", input: { plan: "Ship it" } };
|
||||
const pending = expectMapped(mapClaudeRunningToolCall(proposal));
|
||||
const rejected = expectMapped(
|
||||
mapClaudeFailedToolCall({ ...proposal, error: "Denied by user" }),
|
||||
);
|
||||
const approved = expectMapped(mapClaudeCompletedToolCall(proposal));
|
||||
const canceled = expectMapped(mapClaudeCanceledToolCall(proposal));
|
||||
expect(canceled).toMatchObject({
|
||||
callId: proposal.callId,
|
||||
name: "plan_approval",
|
||||
status: "canceled",
|
||||
detail: { type: "plan", text: "Ship it" },
|
||||
});
|
||||
expect(pending).toMatchObject({
|
||||
callId: "plan-tool-1",
|
||||
detail: { type: "plan", text: "Ship it" },
|
||||
});
|
||||
expect(rejected).toMatchObject({
|
||||
callId: pending.callId,
|
||||
name: "plan_approval",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: pending.detail,
|
||||
metadata: { approved: false },
|
||||
});
|
||||
expect(approved).toMatchObject({
|
||||
callId: pending.callId,
|
||||
name: "plan_approval",
|
||||
status: "completed",
|
||||
detail: pending.detail,
|
||||
metadata: { approved: true },
|
||||
});
|
||||
});
|
||||
it("maps running shell calls with canonical fields", () => {
|
||||
const item = expectMapped(
|
||||
mapClaudeRunningToolCall({
|
||||
|
||||
@@ -12,6 +12,8 @@ interface MapperParams {
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ClaudePlanInputSchema = z.object({ plan: z.string() });
|
||||
|
||||
const ClaudeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
|
||||
type ClaudeToolCallStatus = z.infer<typeof ClaudeToolCallStatusSchema>;
|
||||
|
||||
@@ -113,6 +115,28 @@ function mapClaudeToolCall(
|
||||
}
|
||||
|
||||
const trimmedName = raw.name.trim();
|
||||
if (trimmedName === "ExitPlanMode") {
|
||||
const plan = ClaudePlanInputSchema.safeParse(raw.input);
|
||||
if (plan.success) {
|
||||
// The permission callback and transcript tool_result describe the same call.
|
||||
// Keep its native identity so resolution updates the proposal's original position.
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
// Older clients suppress the pending native call while showing its approval card.
|
||||
name: status === "running" ? trimmedName : "plan_approval",
|
||||
status: status === "failed" ? "completed" : status,
|
||||
error: null,
|
||||
detail: { type: "plan", text: plan.data.plan },
|
||||
metadata: {
|
||||
...raw.metadata,
|
||||
...(status === "completed" || status === "failed"
|
||||
? { approved: status === "completed" }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const toolKind = resolveClaudeToolKind(trimmedName);
|
||||
const name = toolKind === "speak" ? "speak" : trimmedName;
|
||||
const input = raw.input ?? null;
|
||||
|
||||
+82
@@ -11,6 +11,7 @@ import type {
|
||||
AgentLaunchContext,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
@@ -20,6 +21,87 @@ import {
|
||||
|
||||
const logger = createTestLogger();
|
||||
|
||||
interface PendingPlanLifecycle {
|
||||
close(): Promise<void>;
|
||||
crashDuringActiveTurn(): Promise<void>;
|
||||
expectCanceledPlan(): void;
|
||||
}
|
||||
|
||||
async function withPendingPlan(
|
||||
runScenario: (plan: PendingPlanLifecycle) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "codex-plan-cancel-"));
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const client = new ProcessExitCodexClient([appServer]);
|
||||
const session = await client.createSession({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
const run = session.run("make a plan");
|
||||
await appServer.waitForTurnStart();
|
||||
appServer.startsTurn({ threadId: "thread-1", turnId: "plan-turn" });
|
||||
appServer.updatesPlan({ threadId: "thread-1", steps: ["Inspect README"] });
|
||||
appServer.completeTurn();
|
||||
await run;
|
||||
const proposal = events.find(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "plan_approval",
|
||||
);
|
||||
expect(proposal).toBeDefined();
|
||||
const [request] = session.getPendingPermissions?.() ?? [];
|
||||
expect(request).toBeDefined();
|
||||
await runScenario({
|
||||
close: () => session.close(),
|
||||
async crashDuringActiveTurn() {
|
||||
appServer.startsTurn({ threadId: "thread-1", turnId: "autonomous-turn" });
|
||||
await expect
|
||||
.poll(() => events.filter((event) => event.type === "turn_started").length)
|
||||
.toBe(2);
|
||||
appServer.child.emit("exit", 17, null);
|
||||
await expect.poll(() => events.some((event) => event.type === "turn_failed")).toBe(true);
|
||||
},
|
||||
expectCanceledPlan() {
|
||||
expect(session.getPendingPermissions?.()).toEqual([]);
|
||||
expect(events).toContainEqual({
|
||||
...proposal,
|
||||
item: expect.objectContaining({
|
||||
type: "tool_call",
|
||||
name: "plan_approval",
|
||||
callId: request?.id,
|
||||
status: "canceled",
|
||||
detail: { type: "plan", text: "- Inspect README" },
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("closing a session retains a canceled pending plan", async () => {
|
||||
await withPendingPlan(async (plan) => {
|
||||
await plan.close();
|
||||
plan.expectCanceledPlan();
|
||||
});
|
||||
});
|
||||
|
||||
test("an active provider crash retains a canceled pending plan", async () => {
|
||||
await withPendingPlan(async (plan) => {
|
||||
await plan.crashDuringActiveTurn();
|
||||
plan.expectCanceledPlan();
|
||||
});
|
||||
});
|
||||
|
||||
class ProcessExitCodexClient extends CodexAppServerAgentClient implements AgentClient {
|
||||
constructor(private readonly appServers: FakeCodexAppServer[]) {
|
||||
super(logger);
|
||||
|
||||
@@ -5023,14 +5023,22 @@ describe("Codex app-server provider", () => {
|
||||
turn: { status: "completed", error: null },
|
||||
});
|
||||
|
||||
expect(
|
||||
events.some(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.detail.type === "plan",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(events.at(-3)).toEqual({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: session.getPendingPermissions()[0]?.id,
|
||||
name: "plan_approval",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plan",
|
||||
text: "- Inspect the existing auth flow\n- Implement the button behavior",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(events.at(-2)).toEqual({
|
||||
type: "permission_requested",
|
||||
provider: "codex",
|
||||
@@ -5065,7 +5073,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("does not emit Codex plan thread items as timeline cards while plan approval is pending", () => {
|
||||
test("does not complete Codex plan timeline cards while plan approval is pending", () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
@@ -5091,6 +5099,7 @@ describe("Codex app-server provider", () => {
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({
|
||||
type: "tool_call",
|
||||
status: "completed",
|
||||
detail: expect.objectContaining({ type: "plan" }),
|
||||
}),
|
||||
}),
|
||||
@@ -6073,7 +6082,8 @@ describe("Codex denied plan approvals", () => {
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "plan_approval",
|
||||
event.item.name === "plan_approval" &&
|
||||
event.item.status === "completed",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3286,7 +3286,7 @@ interface CodexPendingPermissionHandler {
|
||||
resolve: (value: unknown) => void;
|
||||
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||
questions?: CodexQuestionPrompt[];
|
||||
planText?: string;
|
||||
plan?: { text: string; turnId: string | undefined };
|
||||
}
|
||||
|
||||
interface ConsumedRootCompaction {
|
||||
@@ -3745,7 +3745,20 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingPermissionHandlers.set(requestId, {
|
||||
resolve: () => undefined,
|
||||
kind: "plan",
|
||||
planText,
|
||||
plan: { text: planText, turnId: this.activeForegroundTurnId ?? undefined },
|
||||
});
|
||||
// Reserve the proposal's place before a later prompt can resolve it.
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: requestId,
|
||||
name: "plan_approval",
|
||||
status: "running",
|
||||
error: null,
|
||||
detail: { type: "plan", text: planText },
|
||||
},
|
||||
});
|
||||
this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request });
|
||||
}
|
||||
@@ -4617,7 +4630,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
let followUpPrompt: string | undefined;
|
||||
if (response.behavior === "allow") {
|
||||
followUpPrompt = this.preparePlanImplementation({
|
||||
planText: pending.planText ?? pendingRequest?.metadata?.planText,
|
||||
planText: pending.plan?.text ?? pendingRequest?.metadata?.planText,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4649,29 +4662,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private resolvePlanPermission(requestId: string, resolution: AgentPermissionResponse): void {
|
||||
if (resolution.behavior === "deny") {
|
||||
// Every route into a denial lands here — the response handler, a new
|
||||
// prompt, and an accepted steer — so the transcript record belongs here
|
||||
// rather than in handlePlanPermissionResponse.
|
||||
const planText =
|
||||
this.pendingPermissionHandlers.get(requestId)?.planText ??
|
||||
this.pendingPermissions.get(requestId)?.metadata?.planText;
|
||||
if (typeof planText === "string") {
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: requestId,
|
||||
name: "plan_approval",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plan", text: planText },
|
||||
metadata: { approved: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
this.recordPlanOutcome(requestId, resolution.behavior === "allow" ? "approved" : "rejected");
|
||||
this.pendingPermissionHandlers.delete(requestId);
|
||||
this.pendingPermissions.delete(requestId);
|
||||
this.resolvedPermissionRequests.add(requestId);
|
||||
@@ -4683,6 +4674,29 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private recordPlanOutcome(
|
||||
requestId: string,
|
||||
outcome: "approved" | "rejected" | "canceled",
|
||||
): void {
|
||||
const plan = this.pendingPermissionHandlers.get(requestId)?.plan;
|
||||
if (plan) {
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: CODEX_PROVIDER,
|
||||
turnId: plan.turnId,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: requestId,
|
||||
name: "plan_approval",
|
||||
status: outcome === "canceled" ? "canceled" : "completed",
|
||||
error: null,
|
||||
detail: { type: "plan", text: plan.text },
|
||||
metadata: outcome === "canceled" ? {} : { approved: outcome === "approved" },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private emitDeniedToolCallTimelineEvent(params: {
|
||||
requestId: string;
|
||||
response: Extract<AgentPermissionResponse, { behavior: "deny" }>;
|
||||
@@ -4851,6 +4865,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (options?.preservePlanApprovals && pending.kind === "plan") {
|
||||
continue;
|
||||
}
|
||||
if (pending.kind === "plan") {
|
||||
this.recordPlanOutcome(requestId, "canceled");
|
||||
}
|
||||
pending.resolve({ decision: "cancel" });
|
||||
this.pendingPermissionHandlers.delete(requestId);
|
||||
this.pendingPermissions.delete(requestId);
|
||||
@@ -5172,7 +5189,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId = this.activeForegroundTurnId;
|
||||
const turnId = getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId;
|
||||
const tagged = turnId ? { ...event, turnId } : event;
|
||||
this.logger.trace(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user