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:
Mohamed Boudra
2026-09-12 09:33:26 +02:00
committed by GitHub
parent 03a54a171d
commit 0d7760e68b
26 changed files with 772 additions and 161 deletions
+5
View File
@@ -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));
});
});
+1
View File
@@ -1565,6 +1565,7 @@ function PermissionRequestCard({
title={title}
description={description}
text={planMarkdown}
outcome="pending"
footer={footer}
testID="permission-plan-card"
disableOuterSpacing
+1
View File
@@ -3167,6 +3167,7 @@ export const ToolCall = memo(function ToolCall({
return (
<PlanCard
text={effectiveDetail.text}
outcome={presentation.planOutcome}
testID="timeline-plan-card"
disableOuterSpacing={disableOuterSpacing}
/>
+91 -40
View File
@@ -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,
},
+4
View File
@@ -209,6 +209,10 @@ export const ar: TranslationResources = {
historyLoadFailed: "تعذر تحميل سجل الوكيل",
messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).",
permission: {
rejectedPlan: "خطة مرفوضة",
approvedPlan: "خطة معتمدة",
canceledPlan: "خطة ملغاة",
plan: "يخطط",
required: "الإذن مطلوب",
deny: "ينكر",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -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",
+4
View File
@@ -209,6 +209,10 @@ export const ja: TranslationResources = {
historyLoadFailed: "エージェントの履歴を読み込めませんでした",
messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。",
permission: {
rejectedPlan: "却下されたプラン",
approvedPlan: "承認されたプラン",
canceledPlan: "キャンセルされたプラン",
plan: "プラン",
required: "権限が必要です",
deny: "拒否",
+4
View File
@@ -209,6 +209,10 @@ export const ko: TranslationResources = {
historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.",
messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).",
permission: {
rejectedPlan: "거부된 계획",
approvedPlan: "승인된 계획",
canceledPlan: "취소된 계획",
plan: "계획",
required: "권한 필요",
deny: "거부",
+4
View File
@@ -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",
+4
View File
@@ -209,6 +209,10 @@ export const ru: TranslationResources = {
historyLoadFailed: "Не удалось загрузить историю агента",
messageCapped: "Это сообщение было обрезано ({{bytes}} байт).",
permission: {
rejectedPlan: "Отклонённый план",
approvedPlan: "Одобренный план",
canceledPlan: "Отменённый план",
plan: "План",
required: "Требуется разрешение",
deny: "Отклонить",
+4
View File
@@ -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;
}
+45
View File
@@ -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 = {
-4
View File
@@ -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;
@@ -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(
{