diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 02c5723162..1c3b1e4593 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -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 diff --git a/packages/app/e2e/browser/permission-steer.real.spec.ts b/packages/app/e2e/browser/permission-steer.real.spec.ts index 802ae763ac..e88027cd6c 100644 --- a/packages/app/e2e/browser/permission-steer.real.spec.ts +++ b/packages/app/e2e/browser/permission-steer.real.spec.ts @@ -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 { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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)); + }); + }); +} diff --git a/packages/app/e2e/browser/plan-approval.claude.real.spec.ts b/packages/app/e2e/browser/plan-approval.claude.real.spec.ts new file mode 100644 index 0000000000..2994c63caf --- /dev/null +++ b/packages/app/e2e/browser/plan-approval.claude.real.spec.ts @@ -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 { + 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 { + 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 { + await planWithOutcome(page, label).getByRole("button", { name: label, exact: true }).click(); + await expectPlanOutcome(page, label, expanded); +} + +async function expectClaudeReply(page: Page, marker: string): Promise { + await expect(page.getByTestId("assistant-message").filter({ hasText: marker })).toBeVisible({ + timeout: 180_000, + }); + await assertComposerIdle({ page }); +} + +async function restartIsolatedDaemon(): Promise { + const port = Number(getE2EDaemonPort()); + expect([6767, 6768]).not.toContain(port); + const client = await connectDaemonClient<{ + connect(): Promise; + close(): Promise; + getDaemonStatus(): Promise<{ pid: number }>; + restartServer(reason: string): Promise; + }>({ 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 { + 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, +): Promise { + 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 { + 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 { + 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 { + 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)); + }); +}); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index eb68675580..117132199e 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1565,6 +1565,7 @@ function PermissionRequestCard({ title={title} description={description} text={planMarkdown} + outcome="pending" footer={footer} testID="permission-plan-card" disableOuterSpacing diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index a8a56dee89..99ae316d95 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -3167,6 +3167,7 @@ export const ToolCall = memo(function ToolCall({ return ( diff --git a/packages/app/src/components/plan-card.tsx b/packages/app/src/components/plan-card.tsx index 2ac00f1b7e..52c70683fb 100644 --- a/packages/app/src/components/plan-card.tsx +++ b/packages/app/src/components/plan-card.tsx @@ -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 ; +} + +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 ( - {resolvedTitle} - {description ? {description} : null} - - {text} - + + + + + {resolvedTitle} + + {expanded ? ( + + {description ? {description} : null} + + + ) : null} {footer ? {footer} : null} ); @@ -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, }, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 728374a242..90683d5ae9 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -209,6 +209,10 @@ export const ar: TranslationResources = { historyLoadFailed: "تعذر تحميل سجل الوكيل", messageCapped: "تم اقتطاع هذه الرسالة ({{bytes}} بايت).", permission: { + rejectedPlan: "خطة مرفوضة", + approvedPlan: "خطة معتمدة", + canceledPlan: "خطة ملغاة", + plan: "يخطط", required: "الإذن مطلوب", deny: "ينكر", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3046971400..3ebc7e5b4b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 0eb0e3b092..83955b2ed7 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index e46c5701ec..9eedd70af4 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 09d32471a3..f70f133412 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -209,6 +209,10 @@ export const ja: TranslationResources = { historyLoadFailed: "エージェントの履歴を読み込めませんでした", messageCapped: "このメッセージは上限で切り詰められました({{bytes}}バイト)。", permission: { + rejectedPlan: "却下されたプラン", + approvedPlan: "承認されたプラン", + canceledPlan: "キャンセルされたプラン", + plan: "プラン", required: "権限が必要です", deny: "拒否", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 8d9ca884b0..78ec33c3b4 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -209,6 +209,10 @@ export const ko: TranslationResources = { historyLoadFailed: "에이전트 기록을 로드할 수 없습니다.", messageCapped: "이 메시지는 길이 제한으로 잘렸습니다({{bytes}}바이트).", permission: { + rejectedPlan: "거부된 계획", + approvedPlan: "승인된 계획", + canceledPlan: "취소된 계획", + plan: "계획", required: "권한 필요", deny: "거부", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 75dcd62990..84c4352e11 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 5cd4942ef4..9fdefa4a1e 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -209,6 +209,10 @@ export const ru: TranslationResources = { historyLoadFailed: "Не удалось загрузить историю агента", messageCapped: "Это сообщение было обрезано ({{bytes}} байт).", permission: { + rejectedPlan: "Отклонённый план", + approvedPlan: "Одобренный план", + canceledPlan: "Отменённый план", + plan: "План", required: "Требуется разрешение", deny: "Отклонить", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 6c908d90ad..299aec11fc 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -209,6 +209,10 @@ export const zhCN: TranslationResources = { historyLoadFailed: "无法加载智能体历史记录", messageCapped: "此消息已被截断({{bytes}} 字节)。", permission: { + rejectedPlan: "已拒绝的计划", + approvedPlan: "已批准的计划", + canceledPlan: "已取消的计划", + plan: "Plan", required: "需要权限", deny: "拒绝", diff --git a/packages/app/src/tool-calls/detail-level/projection.test.ts b/packages/app/src/tool-calls/detail-level/projection.test.ts index c0e32c3124..dabf87b73d 100644 --- a/packages/app/src/tool-calls/detail-level/projection.test.ts +++ b/packages/app/src/tool-calls/detail-level/projection.test.ts @@ -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" })]; diff --git a/packages/app/src/tool-calls/detail-level/projection.ts b/packages/app/src/tool-calls/detail-level/projection.ts index 1740e8500b..5dc1067914 100644 --- a/packages/app/src/tool-calls/detail-level/projection.ts +++ b/packages/app/src/tool-calls/detail-level/projection.ts @@ -20,6 +20,24 @@ export interface ToolCallDetailProjection extends GroupedToolCalls(); +// 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(); +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, }); diff --git a/packages/app/src/tool-calls/presentation.ts b/packages/app/src/tool-calls/presentation.ts index e43aacf771..3db13ccbbd 100644 --- a/packages/app/src/tool-calls/presentation.ts +++ b/packages/app/src/tool-calls/presentation.ts @@ -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; +} diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index cd05fd0826..49fdf7daf6 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -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 = { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 4ebd0972db..1e8a32f06f 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -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") diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 4adbf0ce1c..496dfe2687 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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, @@ -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, diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts index 0522d0cd50..f0b3ea2af1 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + mapClaudeCanceledToolCall, mapClaudeCompletedToolCall, mapClaudeFailedToolCall, mapClaudeRunningToolCall, @@ -15,6 +16,40 @@ function expectMapped(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({ diff --git a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts index cc8ed529f0..896cdc962a 100644 --- a/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/claude/tool-call-mapper.ts @@ -12,6 +12,8 @@ interface MapperParams { metadata?: Record; } +const ClaudePlanInputSchema = z.object({ plan: z.string() }); + const ClaudeToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]); type ClaudeToolCallStatus = z.infer; @@ -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; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts index 63a45be061..9cfeca60d3 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts @@ -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; + crashDuringActiveTurn(): Promise; + expectCanceledPlan(): void; +} + +async function withPendingPlan( + runScenario: (plan: PendingPlanLifecycle) => Promise, +): Promise { + 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); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index b86418814d..1b1ff3738d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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", ); } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 5e2cb3795a..8bf9ede447 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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; @@ -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( {