From bcd1f28f9aa41e5a7fb853ede1c2f0b1c89891a6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 28 May 2026 22:12:39 +0700 Subject: [PATCH] Surface plans as first-class timeline items --- packages/app/src/agent-stream/view.tsx | 93 +++++- packages/app/src/types/stream.test.ts | 33 +++ packages/app/src/types/stream.ts | 47 ++- packages/client/src/daemon-client.test.ts | 1 + packages/client/src/daemon-client.ts | 38 +++ packages/protocol/src/agent-types.ts | 20 ++ packages/protocol/src/client-capabilities.ts | 1 + .../src/messages.checkout-pr-schema.test.ts | 2 + packages/protocol/src/messages.ts | 38 +++ .../server/src/server/agent/agent-manager.ts | 27 ++ .../src/server/agent/agent-sdk-types.ts | 25 ++ .../src/server/agent/plan-files.test.ts | 41 +++ .../server/src/server/agent/plan-files.ts | 69 +++++ .../src/server/agent/provider-registry.ts | 1 + .../providers/claude/agent.redesign.test.ts | 28 +- .../server/agent/providers/claude/agent.ts | 219 +++++++++----- .../providers/codex-app-server-agent.test.ts | 94 +++--- .../agent/providers/codex-app-server-agent.ts | 87 ++++-- .../codex-plan-mode.real.e2e.test.ts | 18 +- .../server/agent/providers/opencode-agent.ts | 36 +++ .../src/server/daemon-e2e/plans.e2e.test.ts | 273 ++++++++++++++++++ packages/server/src/server/messages.test.ts | 43 +++ packages/server/src/server/session.ts | 99 ++++++- .../server/test-utils/fake-agent-client.ts | 76 +++++ .../server/src/server/websocket-server.ts | 2 + .../server/src/server/wire-compat.test.ts | 54 ++++ 26 files changed, 1284 insertions(+), 181 deletions(-) create mode 100644 packages/server/src/server/agent/plan-files.test.ts create mode 100644 packages/server/src/server/agent/plan-files.ts create mode 100644 packages/server/src/server/daemon-e2e/plans.e2e.test.ts diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 4e77405cc6..a5b0c3dd60 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -41,6 +41,7 @@ import { PlanCard } from "@/components/plan-card"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; import type { + AgentPlanAction, AgentPermissionAction, AgentPermissionResponse, } from "@getpaseo/protocol/agent-types"; @@ -113,6 +114,86 @@ function renderPendingPermissionsNode(input: { ); } +function PlanTimelineCard({ + item, + agentId, + client, +}: { + item: Extract; + agentId: string; + client: DaemonClient | null; +}) { + const [respondingActionId, setRespondingActionId] = useState(null); + const respondToPlan = useMutation({ + mutationFn: async (action: AgentPlanAction) => { + if (!client) { + throw new Error("No daemon connection"); + } + setRespondingActionId(action.id); + const result = await client.respondToPlan(agentId, item.planId, { actionId: action.id }); + if (!result.ok) { + throw new Error(result.error ?? "Failed to respond to plan"); + } + }, + onSettled: () => setRespondingActionId(null), + }); + + const actions = item.actions ?? []; + + return ( + + + {actions.length > 0 ? ( + + {actions.map((action) => ( + + ))} + + ) : null} + + ); +} + +function PlanActionButton({ + action, + respondingActionId, + isResponding, + onRespond, +}: { + action: AgentPlanAction; + respondingActionId: string | null; + isResponding: boolean; + onRespond: (action: AgentPlanAction) => void; +}) { + const Icon = action.variant === "danger" ? ThemedXIcon : ThemedCheckIcon; + const permissionAction = useMemo( + () => ({ + ...action, + behavior: action.variant === "danger" ? "deny" : "allow", + }), + [action], + ); + const handlePress = useCallback(() => onRespond(action), [action, onRespond]); + + return ( + + ); +} + function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; @@ -549,6 +630,9 @@ const AgentStreamViewComponent = forwardRef; + case "plan": + return ; + case "compaction": return ( @@ -693,6 +706,26 @@ describe("stream reducer canonical tool calls", () => { assert.strictEqual(todos.items[1]?.completed, true); }); + it("converts plan timeline updates to plan items", () => { + const state = hydrateStreamState([ + { + event: planTimeline(), + timestamp: new Date("2025-01-01T10:55:00Z"), + }, + ]); + + const plan = state.find( + (item): item is Extract => item.kind === "plan", + ); + + assert.ok(plan); + assert.strictEqual(plan.planId, "plan-1"); + assert.strictEqual(plan.text, "# Plan\n\n- Implement it"); + assert.deepStrictEqual(plan.actions, [ + { id: "implement", label: "Implement", variant: "primary" }, + ]); + }); + it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => { const state = hydrateStreamState([ { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 44bfa2e354..bd3d92334e 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -1,4 +1,8 @@ -import type { AgentProvider, ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import type { + AgentPlanAction, + AgentProvider, + ToolCallDetail, +} from "@getpaseo/protocol/agent-types"; import type { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages"; import type { AttachmentMetadata } from "@/attachments/types"; import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers"; @@ -48,6 +52,7 @@ export type StreamItem = | AssistantMessageItem | ThoughtItem | ToolCallItem + | PlanItem | TodoListItem | ActivityLogItem | CompactionItem; @@ -168,6 +173,16 @@ export interface TodoListItem { items: TodoEntry[]; } +export interface PlanItem { + kind: "plan"; + id: string; + timestamp: Date; + provider: AgentProvider; + planId: string; + text: string; + actions?: AgentPlanAction[]; +} + export type StreamUpdateSource = "live" | "canonical"; function isRecord(value: unknown): value is Record { @@ -653,6 +668,34 @@ function appendTodoList( return [...state, entry]; } +function appendPlan( + state: StreamItem[], + provider: AgentProvider, + plan: { planId: string; text: string; actions?: AgentPlanAction[] }, + timestamp: Date, +): StreamItem[] { + const existingIndex = state.findIndex( + (item) => item.kind === "plan" && item.provider === provider && item.planId === plan.planId, + ); + const entry: PlanItem = { + kind: "plan", + id: `plan_${plan.planId}`, + timestamp, + provider, + planId: plan.planId, + text: plan.text, + actions: plan.actions, + }; + + if (existingIndex >= 0) { + const next = [...state]; + next[existingIndex] = entry; + return next; + } + + return [...state, entry]; +} + function reduceTimelineToolCall( state: StreamItem[], event: Extract, @@ -774,6 +817,8 @@ function reduceTimelineEvent( })); return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp)); } + case "plan": + return finalizeActiveThoughts(appendPlan(state, event.provider, item, timestamp)); case "error": { const activity: ActivityLogItem = { kind: "activity_log", diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 726371dee8..432e079c28 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -255,6 +255,7 @@ test("advertises client capabilities in hello", async () => { protocolVersion: 1, capabilities: { custom_mode_icons: true, + first_class_plans: true, reasoning_merge_enum: true, }, }); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 64560f33b2..29841917ba 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -84,6 +84,7 @@ import type { import type { AgentPermissionRequest, AgentPermissionResponse, + AgentPlanResponse, AgentPersistenceHandle, AgentProvider, AgentSessionConfig, @@ -363,6 +364,10 @@ type DictationFinishAcceptedPayload = Extract< { type: "dictation_stream_finish_accepted" } >["payload"]; type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; +type AgentPlanRespondPayload = Extract< + SessionOutboundMessage, + { type: "agent.plan.respond.response" } +>["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; export type RenameTerminalResult = z.infer["payload"]; @@ -3613,6 +3618,38 @@ export class DaemonClient { }); } + async respondToPlan( + agentId: string, + planId: string, + response: AgentPlanResponse, + requestId = `plan-response-${Date.now()}-${Math.random().toString(36).slice(2)}`, + timeout = 15000, + ): Promise { + const message = SessionInboundMessageSchema.parse({ + type: "agent.plan.respond.request", + agentId, + planId, + actionId: response.actionId, + ...(response.feedback !== undefined ? { feedback: response.feedback } : {}), + requestId, + }); + return this.sendRequest({ + requestId, + message, + timeout, + options: { skipQueue: true }, + select: (msg) => { + if (msg.type !== "agent.plan.respond.response") { + return null; + } + if (msg.payload.requestId !== requestId) { + return null; + } + return msg.payload; + }, + }); + } + // ============================================================================ // Waiting / Streaming Helpers // ============================================================================ @@ -4278,6 +4315,7 @@ export class DaemonClient { protocolVersion: 1, capabilities: { [CLIENT_CAPS.customModeIcons]: true, + [CLIENT_CAPS.firstClassPlans]: true, [CLIENT_CAPS.reasoningMergeEnum]: true, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 7a1f9c04bc..81d886c58d 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -313,12 +313,32 @@ export interface CompactionTimelineItem { preTokens?: number; } +export interface AgentPlanAction { + id: string; + label: string; + variant?: "primary" | "secondary" | "danger"; +} + +export interface PlanTimelineItem { + [key: string]: unknown; + type: "plan"; + planId: string; + text: string; + actions?: AgentPlanAction[]; +} + +export interface AgentPlanResponse { + actionId: string; + feedback?: string; +} + export type AgentTimelineItem = | { type: "user_message"; text: string; messageId?: string } | { type: "assistant_message"; text: string; messageId?: string } | { type: "reasoning"; text: string } | ToolCallTimelineItem | { type: "todo"; items: { text: string; completed: boolean }[] } + | PlanTimelineItem | { type: "error"; message: string } | CompactionTimelineItem; diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 0b602c137e..dcc2dbe9ee 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -1,4 +1,5 @@ export const CLIENT_CAPS = { + firstClassPlans: "first_class_plans", reasoningMergeEnum: "reasoning_merge_enum", // COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to // a closed enum and crash rendering unknown values; daemon downgrades icons diff --git a/packages/protocol/src/messages.checkout-pr-schema.test.ts b/packages/protocol/src/messages.checkout-pr-schema.test.ts index 85ae910f0f..276e2044a5 100644 --- a/packages/protocol/src/messages.checkout-pr-schema.test.ts +++ b/packages/protocol/src/messages.checkout-pr-schema.test.ts @@ -179,11 +179,13 @@ describe("checkout PR schemas", () => { features: { providersSnapshot: true, checkoutGithubSetAutoMerge: true, + firstClassPlans: true, }, }).features, ).toEqual({ providersSnapshot: true, checkoutGithubSetAutoMerge: true, + firstClassPlans: true, }); }); }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 95ecf2a095..b0403d4b51 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -153,6 +153,7 @@ import type { AgentPermissionRequest, AgentPermissionResponse, AgentPersistenceHandle, + AgentPlanAction, ProviderStatus, AgentRuntimeInfo, AgentTimelineItem, @@ -334,6 +335,12 @@ export const AgentPermissionResponseSchema: z.ZodType = }), ]); +const AgentPlanActionSchema: z.ZodType = z.object({ + id: z.string(), + label: z.string(), + variant: z.enum(["primary", "secondary", "danger"]).optional(), +}); + export const AgentPermissionRequestPayloadSchema: z.ZodType< AgentPermissionRequest, z.ZodTypeDef, @@ -549,6 +556,12 @@ export const AgentTimelineItemPayloadSchema: z.ZodType { + const agent = this.requireAgent(agentId); + if (!agent.session.respondToPlan) { + throw new Error(`Agent provider '${agent.provider}' does not support plan responses`); + } + + const result = await agent.session.respondToPlan(planId, response); + + try { + await this.refreshSessionState(agent); + } catch { + // Ignore refresh errors - state sync after plan response is best effort. + } + + this.touchUpdatedAt(agent); + await this.persistSnapshot(agent); + this.emitState(agent); + + return result; + } + async cancelAgentRun(agentId: string): Promise { const agent = this.requireSessionAgent(agentId); const pendingRun = this.foregroundRuns.getPendingRun(agentId); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 7227de4eaf..0f9b636b6f 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -342,12 +342,27 @@ export interface CompactionTimelineItem { preTokens?: number; } +export interface AgentPlanAction { + id: string; + label: string; + variant?: "primary" | "secondary" | "danger"; +} + +export interface PlanTimelineItem { + [key: string]: unknown; + type: "plan"; + planId: string; + text: string; + actions?: AgentPlanAction[]; +} + export type AgentTimelineItem = | { type: "user_message"; text: string; messageId?: string } | { type: "assistant_message"; text: string; messageId?: string } | { type: "reasoning"; text: string } | ToolCallTimelineItem | { type: "todo"; items: { text: string; completed: boolean }[] } + | PlanTimelineItem | { type: "error"; message: string } | CompactionTimelineItem; @@ -551,6 +566,15 @@ export interface AgentPermissionResult { followUpPrompt?: AgentPromptInput; } +export interface AgentPlanResponse { + actionId: string; + feedback?: string; +} + +export interface AgentPlanResult { + followUpPrompt?: AgentPromptInput; +} + export interface AgentSession { readonly provider: AgentProvider; readonly id: string | null; @@ -569,6 +593,7 @@ export interface AgentSession { requestId: string, response: AgentPermissionResponse, ): Promise; + respondToPlan?(planId: string, response: AgentPlanResponse): Promise; describePersistence(): AgentPersistenceHandle | null; interrupt(): Promise; close(): Promise; diff --git a/packages/server/src/server/agent/plan-files.test.ts b/packages/server/src/server/agent/plan-files.test.ts new file mode 100644 index 0000000000..40eee8a176 --- /dev/null +++ b/packages/server/src/server/agent/plan-files.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "vitest"; +import { isPlanFilePath, planItemFromToolCall } from "./plan-files.js"; + +describe("plan file detection", () => { + test("accepts only narrow Paseo and OpenCode plan markdown paths", () => { + expect(isPlanFilePath(".paseo/plans/feature.md")).toBe(true); + expect(isPlanFilePath("/Users/me/project/.paseo/plans/feature.markdown")).toBe(true); + expect(isPlanFilePath(".opencode/plans/refactor.md")).toBe(true); + expect(isPlanFilePath("/Users/me/.opencode/plans/refactor.markdown")).toBe(true); + + expect(isPlanFilePath("PLAN.md")).toBe(false); + expect(isPlanFilePath("docs/plan.md")).toBe(false); + expect(isPlanFilePath(".paseo/notes/feature.md")).toBe(false); + expect(isPlanFilePath(".paseo/plans/feature.txt")).toBe(false); + }); + + test("turns successful plan writes into non-actionable plan items", async () => { + const item = await planItemFromToolCall({ + cwd: "/workspace", + homeDir: "/Users/me", + item: { + type: "tool_call", + callId: "write-plan", + name: "write", + status: "completed", + error: null, + detail: { + type: "write", + filePath: ".paseo/plans/feature.md", + content: "# Plan\n\n- Implement it", + }, + }, + }); + + expect(item).toEqual({ + type: "plan", + planId: "plan-file:.paseo/plans/feature.md", + text: "# Plan\n\n- Implement it", + }); + }); +}); diff --git a/packages/server/src/server/agent/plan-files.ts b/packages/server/src/server/agent/plan-files.ts new file mode 100644 index 0000000000..e93ee5e886 --- /dev/null +++ b/packages/server/src/server/agent/plan-files.ts @@ -0,0 +1,69 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import type { AgentTimelineItem, ToolCallTimelineItem } from "./agent-sdk-types.js"; + +const PLAN_FILE_EXTENSIONS = new Set([".md", ".markdown"]); +const PLAN_DIRECTORIES = new Set([ + `${path.sep}.paseo${path.sep}plans${path.sep}`, + `${path.sep}.opencode${path.sep}plans${path.sep}`, +]); + +export function isPlanFilePath(filePath: string): boolean { + const normalized = path.normalize(filePath); + const ext = path.extname(normalized).toLowerCase(); + if (!PLAN_FILE_EXTENSIONS.has(ext)) { + return false; + } + const searchable = normalized.startsWith(path.sep) ? normalized : `${path.sep}${normalized}`; + return Array.from(PLAN_DIRECTORIES).some((dir) => searchable.includes(dir)); +} + +export async function planItemFromToolCall(params: { + item: ToolCallTimelineItem; + cwd: string; + homeDir: string; +}): Promise { + const { item, cwd, homeDir } = params; + if (item.status !== "completed") { + return null; + } + const detail = item.detail; + if (detail.type !== "write" && detail.type !== "edit") { + return null; + } + if (!isPlanFilePath(detail.filePath)) { + return null; + } + + const explicitContent = detail.type === "write" ? detail.content : undefined; + const text = explicitContent ?? (await readPlanFile(detail.filePath, cwd, homeDir)); + if (!text?.trim()) { + return null; + } + + return { + type: "plan", + planId: `plan-file:${path.normalize(detail.filePath)}`, + text: text.trim(), + }; +} + +async function readPlanFile( + filePath: string, + cwd: string, + homeDir: string, +): Promise { + const candidates = path.isAbsolute(filePath) + ? [filePath] + : [path.resolve(cwd, filePath), path.resolve(homeDir, filePath)]; + + for (const candidate of candidates) { + try { + return await fs.readFile(candidate, "utf8"); + } catch { + // Try the next candidate. + } + } + + return null; +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index b474047fe7..8920147705 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -363,6 +363,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession setMode: (modeId) => inner.setMode(modeId), getPendingPermissions: () => inner.getPendingPermissions(), respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response), + respondToPlan: inner.respondToPlan?.bind(inner), describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()), interrupt: () => inner.interrupt(), close: () => inner.close(), diff --git a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts index fd32340e73..3c970ed323 100644 --- a/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.redesign.test.ts @@ -997,7 +997,7 @@ test("preserves bypass capability across query restarts triggered by thinking ch } }); -test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => { +test("plan item exposes a resume-bypass action and can return to bypassPermissions", async () => { const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); sdkQueryFactory.mockImplementation(() => queryMock); @@ -1023,44 +1023,36 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi {}, ); - const requestEvent = events.find( - (event): event is Extract => - event.type === "permission_requested" && event.request.kind === "plan", + const planEvent = events.find( + (event): event is Extract => + event.type === "timeline" && event.item.type === "plan", ); - expect(requestEvent).toBeDefined(); - expect(requestEvent?.request.actions).toEqual([ + expect(planEvent).toBeDefined(); + expect(planEvent?.item.type === "plan" ? planEvent.item.actions : undefined).toEqual([ { id: "reject", label: "Reject", - behavior: "deny", variant: "danger", - intent: "dismiss", }, { id: "implement", label: "Implement", - behavior: "allow", variant: "primary", - intent: "implement", }, { id: "implement_resume", label: "Implement with Bypass", - behavior: "allow", variant: "secondary", - intent: "implement_resume", }, ]); - if (!requestEvent) { - throw new Error("Expected plan permission request"); + if (!planEvent || planEvent.item.type !== "plan") { + throw new Error("Expected plan item"); } + expect(session.getPendingPermissions()).toEqual([]); - await session.respondToPermission(requestEvent.request.id, { - behavior: "allow", - selectedActionId: "implement_resume", - }); + await session.respondToPlan?.(planEvent.item.planId, { actionId: "implement_resume" }); await expect(pendingResolution).resolves.toMatchObject({ behavior: "allow", diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index d35aa1bd18..b702bcfdda 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -57,6 +57,8 @@ import { type AgentMetadata, type AgentMode, type AgentModelDefinition, + type AgentPlanAction, + type AgentPlanResponse, type AgentPermissionRequest, type AgentPermissionRequestKind, type AgentPermissionResponse, @@ -897,6 +899,14 @@ function buildClaudePlanPermissionActions( return actions; } +function buildClaudePlanActions(resumeMode: PermissionMode | null): AgentPlanAction[] { + return buildClaudePlanPermissionActions(resumeMode).map(({ id, label, variant }) => ({ + id, + label, + variant, + })); +} + interface TimelineFragment { kind: "assistant" | "reasoning"; text: string; @@ -1584,6 +1594,7 @@ class ClaudeAgentSession implements AgentSession { private toolUseIndexToId = new Map(); private toolUseInputBuffers = new Map(); private pendingPermissions = new Map(); + private pendingPlans = new Map(); private activeForegroundTurnId: string | null = null; private autonomousTurn: AutonomousTurnState | null = null; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); @@ -1919,82 +1930,140 @@ class ClaudeAgentSession implements AgentSession { } getPendingPermissions(): AgentPermissionRequest[] { - return Array.from(this.pendingPermissions.values()).map((entry) => entry.request); + const hiddenPlanRequestIds = new Set(this.pendingPlans.values()); + return Array.from(this.pendingPermissions.values()) + .filter((entry) => !hiddenPlanRequestIds.has(entry.request.id)) + .map((entry) => entry.request); } - async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + private clearPendingPlanForPermission(requestId: string): void { + for (const [planId, pendingRequestId] of this.pendingPlans) { + if (pendingRequestId === requestId) { + this.pendingPlans.delete(planId); + } + } + } + + async respondToPermission( + requestId: string, + response: AgentPermissionResponse, + emitResolution = true, + ): Promise { const pending = this.pendingPermissions.get(requestId); if (!pending) { throw new Error(`No pending permission request with id '${requestId}'`); } this.pendingPermissions.delete(requestId); + this.clearPendingPlanForPermission(requestId); pending.cleanup?.(); if (response.behavior === "allow") { - if (pending.request.kind === "plan") { - const selectedActionId = response.selectedActionId; - const shouldResumePriorMode = - selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions"; - const targetMode: PermissionMode = shouldResumePriorMode - ? "bypassPermissions" - : "acceptEdits"; - await this.setMode(targetMode); - this.pushToolCall( - mapClaudeCompletedToolCall({ - name: "plan_approval", - callId: pending.request.id, - input: pending.request.input ?? null, - output: { - approved: true, - actionId: selectedActionId ?? "implement", - }, - }), - ); - } - const updatedInput = - pending.request.kind === "question" - ? normalizeClaudeAskUserQuestionUpdatedInput( - response.updatedInput, - pending.request.input ?? undefined, - ) - : (response.updatedInput ?? pending.request.input ?? {}); - const result: PermissionResult = { - behavior: "allow", - updatedInput, - updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions), - }; - pending.resolve(result); + await this.resolveAllowedPermission(pending, response); } else { - if (pending.request.kind === "tool") { - this.pushToolCall( - mapClaudeFailedToolCall({ - name: pending.request.name, - callId: - (typeof pending.request.metadata?.toolUseId === "string" - ? pending.request.metadata.toolUseId - : null) ?? pending.request.id, - input: pending.request.input ?? null, - output: null, - error: { message: response.message ?? "Permission denied" }, - }), - ); - } - const result: PermissionResult = { - behavior: "deny", - message: response.message ?? "Permission request denied", - interrupt: response.interrupt, - }; - pending.resolve(result); + this.resolveDeniedPermission(pending, response); } - this.pushEvent({ - type: "permission_resolved", - provider: "claude", - requestId, - resolution: response, + if (emitResolution) { + this.pushEvent({ + type: "permission_resolved", + provider: "claude", + requestId, + resolution: response, + }); + } + } + + private async resolveAllowedPermission( + pending: PendingPermission, + response: Extract, + ): Promise { + if (pending.request.kind === "plan") { + const selectedActionId = response.selectedActionId; + const shouldResumePriorMode = + selectedActionId === "implement_resume" && this.planResumeMode === "bypassPermissions"; + const targetMode: PermissionMode = shouldResumePriorMode + ? "bypassPermissions" + : "acceptEdits"; + await this.setMode(targetMode); + this.pushToolCall( + mapClaudeCompletedToolCall({ + name: "plan_approval", + callId: pending.request.id, + input: pending.request.input ?? null, + output: { + approved: true, + actionId: selectedActionId ?? "implement", + }, + }), + ); + } + const updatedInput = + pending.request.kind === "question" + ? normalizeClaudeAskUserQuestionUpdatedInput( + response.updatedInput, + pending.request.input ?? undefined, + ) + : (response.updatedInput ?? pending.request.input ?? {}); + pending.resolve({ + behavior: "allow", + updatedInput, + updatedPermissions: this.normalizePermissionUpdates(response.updatedPermissions), }); } + private resolveDeniedPermission( + pending: PendingPermission, + response: Extract, + ): void { + if (pending.request.kind === "tool") { + this.pushToolCall( + mapClaudeFailedToolCall({ + name: pending.request.name, + callId: + (typeof pending.request.metadata?.toolUseId === "string" + ? pending.request.metadata.toolUseId + : null) ?? pending.request.id, + input: pending.request.input ?? null, + output: null, + error: { message: response.message ?? "Permission denied" }, + }), + ); + } + pending.resolve({ + behavior: "deny", + message: response.message ?? "Permission request denied", + interrupt: response.interrupt, + }); + } + + async respondToPlan(planId: string, response: AgentPlanResponse): Promise { + const requestId = this.pendingPlans.get(planId); + if (!requestId) { + throw new Error(`No pending Claude plan with id '${planId}'`); + } + this.pendingPlans.delete(planId); + + if (response.actionId === "implement" || response.actionId === "implement_resume") { + await this.respondToPermission( + requestId, + { behavior: "allow", selectedActionId: response.actionId }, + false, + ); + return; + } + + if (response.actionId === "reject") { + await this.respondToPermission( + requestId, + { behavior: "deny", selectedActionId: response.actionId, message: response.feedback }, + false, + ); + return; + } + + throw new Error(`Unknown Claude plan action '${response.actionId}'`); + } + describePersistence(): AgentPersistenceHandle | null { if (this.persistence) { return this.persistence; @@ -3774,11 +3843,26 @@ class ClaudeAgentSession implements AgentSession { metadata: Object.keys(metadata).length ? metadata : undefined, }; - this.pushEvent({ - type: "permission_requested", - provider: "claude", - request, - }); + if (kind === "plan" && typeof input.plan === "string") { + const planId = `plan-${randomUUID()}`; + this.pendingPlans.set(planId, requestId); + this.pushEvent({ + type: "timeline", + provider: "claude", + item: { + type: "plan", + planId, + text: input.plan, + actions: buildClaudePlanActions(this.planResumeMode), + }, + }); + } else { + this.pushEvent({ + type: "permission_requested", + provider: "claude", + request, + }); + } return await new Promise((resolve, reject) => { const cleanupFns: Array<() => void> = []; @@ -3795,6 +3879,11 @@ class ClaudeAgentSession implements AgentSession { const abortHandler = () => { this.pendingPermissions.delete(requestId); + for (const [planId, pendingRequestId] of this.pendingPlans) { + if (pendingRequestId === requestId) { + this.pendingPlans.delete(planId); + } + } cleanup(); reject(new Error("Permission request aborted")); }; 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 ffa0b66d2d..4db13f23a0 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 @@ -1989,7 +1989,7 @@ describe("Codex app-server provider", () => { }); }); - test("emits a synthetic plan approval permission after a successful Codex plan turn", () => { + test("emits an actionable plan item after a successful Codex plan turn", () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); @@ -2018,27 +2018,20 @@ describe("Codex app-server provider", () => { ), ).toBe(false); expect(events.at(-2)).toEqual({ - type: "permission_requested", + type: "timeline", provider: "codex", turnId: "test-turn", - request: expect.objectContaining({ - provider: "codex", - name: "CodexPlanApproval", - kind: "plan", - title: "Plan", - input: { - plan: "- Inspect the existing auth flow\n- Implement the button behavior", - }, + item: expect.objectContaining({ + type: "plan", + text: "- Inspect the existing auth flow\n- Implement the button behavior", actions: [ expect.objectContaining({ id: "reject", label: "Reject", - behavior: "deny", }), expect.objectContaining({ id: "implement", label: "Implement", - behavior: "allow", }), ], }), @@ -2082,16 +2075,12 @@ describe("Codex app-server provider", () => { }), ); expect(events.at(-2)).toEqual({ - type: "permission_requested", + type: "timeline", provider: "codex", turnId: "test-turn", - request: expect.objectContaining({ - provider: "codex", - name: "CodexPlanApproval", - kind: "plan", - input: { - plan: "- Inspect README\n- Add a short note", - }, + item: expect.objectContaining({ + type: "plan", + text: "- Inspect README\n- Add a short note", }), }); }); @@ -2469,7 +2458,7 @@ describe("Codex app-server provider", () => { ]); }); - test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => { + test("responding to a Codex plan item disables plan mode, preserves fast mode, and returns follow-up prompt", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, }); @@ -2486,19 +2475,16 @@ describe("Codex app-server provider", () => { turn: { status: "completed", error: null }, }); - const request = events.find( - (event): event is Extract => - event.type === "permission_requested" && event.request.kind === "plan", + const plan = events.find( + (event): event is Extract => + event.type === "timeline" && event.item.type === "plan", ); - expect(request).toBeDefined(); - if (!request) { - throw new Error("Expected synthetic plan approval permission"); + expect(plan).toBeDefined(); + if (!plan || plan.item.type !== "plan") { + throw new Error("Expected plan item"); } - const result = await session.respondToPermission(request.request.id, { - behavior: "allow", - selectedActionId: "implement", - }); + const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" }); expect(asInternals(session).serviceTier).toBe("fast"); expect(asInternals(session).planModeEnabled).toBe(false); @@ -2512,18 +2498,10 @@ describe("Codex app-server provider", () => { expect(result!.followUpPrompt).toEqual( expect.stringContaining("The user approved the plan. Implement it now."), ); - expect(events.at(-1)).toEqual({ - type: "permission_resolved", - provider: "codex", - requestId: request.request.id, - resolution: { - behavior: "allow", - selectedActionId: "implement", - }, - }); + expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_resolved" })); }); - test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => { + test("responding to a Codex plan item keeps fast mode disabled when it started disabled", async () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: false }, }); @@ -2540,19 +2518,16 @@ describe("Codex app-server provider", () => { turn: { status: "completed", error: null }, }); - const request = events.find( - (event): event is Extract => - event.type === "permission_requested" && event.request.kind === "plan", + const plan = events.find( + (event): event is Extract => + event.type === "timeline" && event.item.type === "plan", ); - expect(request).toBeDefined(); - if (!request) { - throw new Error("Expected synthetic plan approval permission"); + expect(plan).toBeDefined(); + if (!plan || plan.item.type !== "plan") { + throw new Error("Expected plan item"); } - const result = await session.respondToPermission(request.request.id, { - behavior: "allow", - selectedActionId: "implement", - }); + const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" }); expect(asInternals(session).serviceTier).toBeNull(); expect(asInternals(session).planModeEnabled).toBe(false); @@ -2608,19 +2583,16 @@ describe("Codex app-server provider", () => { turn: { status: "completed", error: null }, }); - const permissionRequest = events.find( - (event): event is Extract => - event.type === "permission_requested" && event.request.kind === "plan", + const plan = events.find( + (event): event is Extract => + event.type === "timeline" && event.item.type === "plan", ); - expect(permissionRequest).toBeDefined(); - if (!permissionRequest) { - throw new Error("Expected synthetic plan approval permission"); + expect(plan).toBeDefined(); + if (!plan || plan.item.type !== "plan") { + throw new Error("Expected plan item"); } - const result = await session.respondToPermission(permissionRequest.request.id, { - behavior: "allow", - selectedActionId: "implement", - }); + const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" }); expect(result?.followUpPrompt).toEqual(expect.any(String)); await session.startTurn(result!.followUpPrompt!); 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 78609f13f1..0f7e764210 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 @@ -8,6 +8,9 @@ import { type AgentLaunchContext, type AgentMode, type AgentModelDefinition, + type AgentPlanAction, + type AgentPlanResponse, + type AgentPlanResult, type McpServerConfig, type AgentPersistenceHandle, type AgentPermissionRequest, @@ -41,6 +44,7 @@ import os from "node:os"; import path from "node:path"; import { z } from "zod"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; +import { planItemFromToolCall } from "../plan-files.js"; import { composeSystemPromptParts } from "../system-prompt.js"; import { curateAgentActivity } from "../activity-curator.js"; import { @@ -941,6 +945,10 @@ function buildPlanPermissionActions(options?: { return actions; } +function buildPlanActions(): AgentPlanAction[] { + return buildPlanPermissionActions().map(({ id, label, variant }) => ({ id, label, variant })); +} + function buildCodexPlanImplementationPrompt(planText: string): string { const normalizedPlan = normalizePlanMarkdown(planText); if (!normalizedPlan) { @@ -2925,6 +2933,7 @@ export class CodexAppServerAgentSession implements AgentSession { private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null; private readonly userMessageTurnIndexes = new Map(); private readonly userMessageTurnIds: string[] = []; + private pendingPlans = new Map(); private pendingManualCompactionStarts = 0; private compactionTriggerByItemId = new Map(); // Codex can report one completed compaction through both channels: @@ -3187,30 +3196,36 @@ export class CodexAppServerAgentSession implements AgentSession { }; } - private emitSyntheticPlanApprovalRequest(planText: string): void { - const requestId = `permission-${randomUUID()}`; - const request: AgentPermissionRequest = { - id: requestId, - provider: CODEX_PROVIDER, - name: "CodexPlanApproval", - kind: "plan", - title: "Plan", - description: "Review the proposed plan before implementation starts.", - input: { plan: planText }, - actions: buildPlanPermissionActions(), - metadata: { - planText, - source: "codex_plan_approval", - }, - }; + private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem): void { + void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() }) + .then((planItem) => { + if (planItem) { + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: planItem }); + } + return undefined; + }) + .catch((error) => { + this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item"); + }); + } - this.pendingPermissions.set(requestId, request); - this.pendingPermissionHandlers.set(requestId, { - resolve: () => undefined, - kind: "plan", - planText, + private emitPlanApprovalItem(planText: string): void { + const text = normalizePlanMarkdown(planText); + if (!text) { + return; + } + const planId = `plan-${randomUUID()}`; + this.pendingPlans.set(planId, { text }); + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: { + type: "plan", + planId, + text, + actions: buildPlanActions(), + }, }); - this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); } /** @@ -3770,6 +3785,29 @@ export class CodexAppServerAgentSession implements AgentSession { pending.resolve({ answers: {} }); } + async respondToPlan( + planId: string, + response: AgentPlanResponse, + ): Promise { + const pending = this.pendingPlans.get(planId); + if (!pending) { + throw new Error(`No pending Codex app-server plan with id '${planId}'`); + } + this.pendingPlans.delete(planId); + + if (response.actionId === "implement" || response.actionId === "implement_resume") { + return { + followUpPrompt: this.preparePlanImplementation({ planText: pending.text }), + }; + } + + if (response.actionId === "reject") { + return; + } + + throw new Error(`Unknown Codex plan action '${response.actionId}'`); + } + private handlePlanPermissionResponse(params: { requestId: string; response: AgentPermissionResponse; @@ -4569,7 +4607,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" }); } else { if (this.planModeEnabled && this.latestPlanResult?.text) { - this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text); + this.emitPlanApprovalItem(this.latestPlanResult.text); } this.emitEvent({ type: "turn_completed", @@ -4883,6 +4921,9 @@ export class CodexAppServerAgentSession implements AgentSession { this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); } this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + if (timelineItem.type === "tool_call") { + this.emitPlanFileItemFromToolCall(timelineItem); + } if (timelineItem.type === "assistant_message") { this.pendingAssistantMessageBoundary = true; } diff --git a/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts b/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts index dd87d71373..dd18a90e8d 100644 --- a/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-plan-mode.real.e2e.test.ts @@ -24,7 +24,7 @@ describe("Codex app-server provider (real) plan mode", () => { } }); - test("maps gpt-5.4 markdown plans to a plan tool call instead of todo items", async () => { + test("maps gpt-5.4 markdown plans to a normalized plan item instead of todo items", async () => { const cwd = tmpCwd(); const client = new CodexAppServerAgentClient(createTestLogger()); @@ -50,18 +50,16 @@ describe("Codex app-server provider (real) plan mode", () => { }), ); - const planCall = result.timeline.find( - (item) => item.type === "tool_call" && item.detail.type === "plan", - ); + const planItem = result.timeline.find((item) => item.type === "plan"); - expect(planCall).toBeDefined(); - if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") { - throw new Error("Expected a plan tool call"); + expect(planItem).toBeDefined(); + if (!planItem || planItem.type !== "plan") { + throw new Error("Expected a normalized plan item"); } - expect(planCall.detail.text).toContain("Login"); - expect(planCall.detail.text).toContain("- "); - expect(result.finalText).toBe(planCall.detail.text); + expect(planItem.text).toContain("Login"); + expect(planItem.text).toContain("- "); + expect(planItem.actions?.some((action) => action.id === "implement")).toBe(true); } finally { await session.close(); } diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 09485c6591..d95009a564 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -57,6 +57,7 @@ import { resolveProviderLaunch, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; +import { isPlanFilePath, planItemFromToolCall } from "../plan-files.js"; import { withTimeout } from "../../../utils/promise-timeout.js"; import { execCommand } from "../../../utils/spawn.js"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; @@ -2154,6 +2155,22 @@ function appendOpenCodeToolCallTimelineItem( provider: "opencode", item: timelineItem, }); + if ( + timelineItem.status === "completed" && + timelineItem.detail.type === "write" && + timelineItem.detail.content?.trim() && + isPlanFilePath(timelineItem.detail.filePath) + ) { + events.push({ + type: "timeline", + provider: "opencode", + item: { + type: "plan", + planId: `plan-file:${timelineItem.detail.filePath}`, + text: timelineItem.detail.content.trim(), + }, + }); + } if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) { flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events); } @@ -3213,9 +3230,28 @@ class OpenCodeAgentSession implements AgentSession { return; } this.notifySubscribers(e, turnId); + if (e.type === "timeline" && e.item.type === "tool_call") { + this.emitPlanFileItemFromToolCall(e.item, turnId); + } } } + private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem, turnId: string): void { + void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() }) + .then((planItem) => { + if (planItem) { + this.notifySubscribers( + { type: "timeline", provider: "opencode", item: planItem }, + turnId, + ); + } + return undefined; + }) + .catch((error) => { + this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item"); + }); + } + private finishForegroundTurn( event: Extract, turnId: string, diff --git a/packages/server/src/server/daemon-e2e/plans.e2e.test.ts b/packages/server/src/server/daemon-e2e/plans.e2e.test.ts new file mode 100644 index 0000000000..1e33b5c130 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/plans.e2e.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; + +import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; +import { createMessageCollector, type MessageCollector } from "../test-utils/message-collector.js"; +import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; +import { isProviderAvailable } from "./agent-configs.js"; +import type { PlanTimelineItem } from "../agent/agent-sdk-types.js"; + +function tmpCwd(): string { + return mkdtempSync(path.join(tmpdir(), "daemon-plans-")); +} + +function waitForPlanMessage( + collector: MessageCollector, + agentId: string, + timeoutMs: number, +): Promise { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + const timer = setInterval(() => { + const message = collector.messages.find((candidate) => { + if (candidate.type !== "agent_stream") return false; + if (candidate.payload.agentId !== agentId) return false; + return ( + candidate.payload.event.type === "timeline" && + candidate.payload.event.item.type === "plan" + ); + }); + if (message?.type === "agent_stream") { + const event = message.payload.event; + if (event.type === "timeline" && event.item.type === "plan") { + clearInterval(timer); + resolve(event.item); + } + } + if (Date.now() > deadline) { + clearInterval(timer); + reject(new Error(`Timed out waiting for plan item after ${timeoutMs}ms`)); + } + }, 100); + }); +} + +describe("daemon E2E - first-class plans", () => { + let ctx: DaemonTestContext; + let collector: MessageCollector; + + beforeEach(async () => { + ctx = await createDaemonTestContext(); + collector = createMessageCollector(ctx.client); + }); + + afterEach(async () => { + collector.unsubscribe(); + await ctx.cleanup(); + }, 60_000); + + test("surfaces an actionable plan and routes the response through the daemon", async () => { + const cwd = tmpCwd(); + try { + const agent = await ctx.client.createAgent({ + provider: "codex", + cwd, + title: "Plan E2E", + modeId: "full-access", + }); + + collector.clear(); + await ctx.client.sendMessage(agent.id, "Emit an actionable plan."); + const finalState = await ctx.client.waitForFinish(agent.id, 5_000); + expect(finalState.status).toBe("idle"); + + const planMessage = collector.messages.find((message) => { + if (message.type !== "agent_stream") return false; + if (message.payload.agentId !== agent.id) return false; + return ( + message.payload.event.type === "timeline" && message.payload.event.item.type === "plan" + ); + }); + expect(planMessage?.type).toBe("agent_stream"); + if (planMessage?.type !== "agent_stream") { + throw new Error("Expected plan stream message"); + } + const event = planMessage.payload.event; + if (event.type !== "timeline" || event.item.type !== "plan") { + throw new Error("Expected normalized plan item"); + } + expect(event.item.actions).toEqual([ + { id: "implement", label: "Implement", variant: "primary" }, + ]); + + const timeline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + expect( + timeline.entries.some( + (entry) => entry.item.type === "plan" && entry.item.planId === event.item.planId, + ), + ).toBe(true); + + const response = await ctx.client.respondToPlan(agent.id, event.item.planId, { + actionId: "implement", + }); + expect(response).toMatchObject({ + agentId: agent.id, + planId: event.item.planId, + ok: true, + error: null, + }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 30_000); + + test("surfaces a plan file as a non-actionable plan", async () => { + const cwd = tmpCwd(); + try { + const agent = await ctx.client.createAgent({ + provider: "opencode", + cwd, + title: "Plan File E2E", + modeId: "full-access", + }); + + collector.clear(); + await ctx.client.sendMessage(agent.id, "Emit a plan file."); + await ctx.client.waitForFinish(agent.id, 5_000); + + const timeline = await ctx.client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const plan = timeline.entries.find( + (entry) => + entry.item.type === "plan" && entry.item.planId === "plan-file:.paseo/plans/fake.md", + ); + + expect(plan?.item).toEqual({ + type: "plan", + planId: "plan-file:.paseo/plans/fake.md", + text: "# File plan\n\n- From disk", + }); + const response = await ctx.client.respondToPlan(agent.id, "plan-file:.paseo/plans/fake.md", { + actionId: "implement", + }); + expect(response.ok).toBe(false); + expect(response.error).toContain("No pending fake plan"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, 30_000); +}); + +describe("daemon E2E - first-class plans with real providers", () => { + test("real Codex plan mode surfaces a normalized actionable plan", async (context) => { + if (!(await isProviderAvailable("codex"))) { + context.skip(); + } + + const cwd = tmpCwd(); + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { codex: new CodexAppServerAgentClient(logger) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "real-codex-plan" } }); + const agent = await client.createAgent({ + provider: "codex", + cwd, + title: "Real Codex Plan E2E", + modeId: "auto", + model: "gpt-5.4", + thinkingOptionId: "medium", + featureValues: { plan_mode: true }, + }); + + await client.sendMessage( + agent.id, + "You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.", + ); + await client.waitForFinish(agent.id, 240_000); + + const timeline = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const plan = timeline.entries.find((entry) => entry.item.type === "plan"); + + expect(plan?.item.type).toBe("plan"); + if (!plan || plan.item.type !== "plan") { + throw new Error("Expected normalized plan item"); + } + expect(plan.item.text).toContain("Login"); + expect(plan.item.actions?.some((action) => action.id === "implement")).toBe(true); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 300_000); + + test("real Claude plan mode surfaces a normalized actionable plan", async (context) => { + if (!(await isProviderAvailable("claude"))) { + context.skip(); + } + + const cwd = tmpCwd(); + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + logger, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + const collector = createMessageCollector(client); + + try { + await client.connect(); + await client.fetchAgents({ subscribe: { subscriptionId: "real-claude-plan" } }); + const agent = await client.createAgent({ + provider: "claude", + cwd, + title: "Real Claude Plan E2E", + modeId: "plan", + model: "haiku", + }); + + collector.clear(); + await client.sendMessage( + agent.id, + [ + "Create a short implementation plan for a login screen.", + "Use plan mode and call ExitPlanMode with a markdown plan.", + "Do not edit files.", + ].join(" "), + ); + + const plan = await waitForPlanMessage(collector, agent.id, 120_000); + expect(plan.text).toContain("login"); + expect(plan.actions?.some((action) => action.id === "implement")).toBe(true); + + const snapshot = await client.fetchAgent(agent.id); + expect(snapshot.agent?.pendingPermissions ?? []).toEqual([]); + + const response = await client.respondToPlan(agent.id, plan.planId, { actionId: "reject" }); + expect(response).toMatchObject({ + agentId: agent.id, + planId: plan.planId, + ok: true, + error: null, + }); + } finally { + collector.unsubscribe(); + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, 180_000); +}); diff --git a/packages/server/src/server/messages.test.ts b/packages/server/src/server/messages.test.ts index 411d81c789..71dc9cf6a7 100644 --- a/packages/server/src/server/messages.test.ts +++ b/packages/server/src/server/messages.test.ts @@ -63,6 +63,49 @@ describe("serializeAgentStreamEvent", () => { expect(serialized.item.messageId).toBe("m1"); }); + test("accepts normalized plan timeline items", () => { + const event: AgentStreamEvent = { + type: "timeline", + provider: "codex", + item: { + type: "plan", + planId: "plan-1", + text: "# Plan\n\n- Ship it", + actions: [{ id: "implement", label: "Implement", variant: "primary" }], + }, + }; + + const serialized = serializeAgentStreamEvent(event); + + expect(serialized).toMatchObject({ + type: "timeline", + item: { + type: "plan", + planId: "plan-1", + text: "# Plan\n\n- Ship it", + actions: [{ id: "implement", label: "Implement", variant: "primary" }], + }, + }); + }); + + test("accepts plan response requests", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "agent.plan.respond.request", + agentId: "agent-1", + planId: "plan-1", + actionId: "implement", + requestId: "req-plan-1", + }); + + expect(parsed).toMatchObject({ + type: "agent.plan.respond.request", + agentId: "agent-1", + planId: "plan-1", + actionId: "implement", + requestId: "req-plan-1", + }); + }); + test("passes canonical tool_call payloads through unchanged", () => { const event: AgentStreamEvent = { type: "timeline", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 172aaebd5b..806d974adc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -68,6 +68,7 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js"; import { formatSystemNotificationPrompt, sendPromptToAgent, + startAgentRun, waitForAgentRunStartWithTimeout, unarchiveAgentState, } from "./agent/agent-prompt.js"; @@ -142,6 +143,8 @@ import { type AgentPromptInput, type AgentRunOptions, type AgentSessionConfig, + type AgentStreamEvent, + type AgentTimelineItem, type ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; @@ -716,6 +719,45 @@ function parseClientCapabilities( return new Set(result); } +function projectTimelineItemForClient( + item: AgentTimelineItem, + capabilities: ReadonlySet, +): AgentTimelineItem { + if (item.type !== "plan" || capabilities.has(CLIENT_CAPS.firstClassPlans)) { + return item; + } + + // COMPAT(firstClassPlans): added in v0.1.82, remove shim after 2026-11-28. + return { + type: "tool_call", + callId: item.planId, + name: "Plan", + status: "completed", + error: null, + detail: { + type: "plan", + text: item.text, + }, + }; +} + +function projectAgentStreamEventForClient( + event: AgentStreamEvent, + capabilities: ReadonlySet, +): AgentStreamEvent { + if (event.type !== "timeline") { + return event; + } + const item = projectTimelineItemForClient(event.item, capabilities); + if (item === event.item) { + return event; + } + return { + ...event, + item, + }; +} + /** * Session represents a single connected client session. * It owns all state management, orchestration logic, and message processing. @@ -1335,7 +1377,11 @@ export class Session { }); } - const serializedEvent = serializeAgentStreamEvent(event.event); + const projectedEvent = projectAgentStreamEventForClient( + event.event, + this.clientCapabilities, + ); + const serializedEvent = serializeAgentStreamEvent(projectedEvent); if (!serializedEvent) { return; } @@ -1740,6 +1786,7 @@ export class Session { const promise = this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ?? + this.dispatchAgentPlanMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -1751,6 +1798,13 @@ export class Session { if (promise) await promise; } + private dispatchAgentPlanMessage(msg: SessionInboundMessage): Promise | undefined { + if (msg.type === "agent.plan.respond.request") { + return this.handleAgentPlanRespondRequest(msg); + } + return undefined; + } + private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise | undefined { switch (msg.type) { case "voice_audio_chunk": @@ -4561,6 +4615,47 @@ export class Session { } } + private async handleAgentPlanRespondRequest( + msg: Extract, + ): Promise { + const { agentId, planId, actionId, feedback, requestId } = msg; + try { + const result = await this.agentManager.respondToPlan(agentId, planId, { actionId, feedback }); + this.emit({ + type: "agent.plan.respond.response", + payload: { + requestId, + agentId, + planId, + ok: true, + error: null, + }, + }); + + if (result?.followUpPrompt) { + startAgentRun(this.agentManager, agentId, result.followUpPrompt, this.sessionLogger, { + replaceRunning: true, + }); + } + } catch (error) { + const message = getErrorMessage(error); + this.sessionLogger.error( + { err: error, agentId, planId, actionId }, + "Failed to respond to plan", + ); + this.emit({ + type: "agent.plan.respond.response", + payload: { + requestId, + agentId, + planId, + ok: false, + error: message, + }, + }); + } + } + private async handleCheckoutStatusRequest( msg: Extract, ): Promise { @@ -7565,7 +7660,7 @@ export class Session { hasNewer, entries: entries.map((entry) => ({ provider: snapshot.provider, - item: entry.item, + item: projectTimelineItemForClient(entry.item, this.clientCapabilities), timestamp: entry.timestamp, seqStart: entry.seqStart, seqEnd: entry.seqEnd, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index c29eeac015..f65d223517 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -10,6 +10,8 @@ import type { AgentLaunchContext, AgentMode, AgentModelDefinition, + AgentPlanResponse, + AgentPlanResult, AgentPersistenceHandle, AgentPromptInput, AgentRunOptions, @@ -304,6 +306,7 @@ class FakeAgentSession implements AgentSession { private memoryMarker: string | null = null; private pendingPermissions: AgentPermissionRequest[] = []; private permissionGate: Deferred | null = null; + private pendingPlans = new Map(); private readonly historyPath: string; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; @@ -522,6 +525,55 @@ class FakeAgentSession implements AgentSession { this.notifySubscribers(completed); } + private async emitActionablePlanTurn(text: string): Promise { + const planId = `fake-plan-${randomUUID()}`; + const planText = text.includes("custom plan body") ? "custom plan body" : "# Plan\n\n- Test it"; + this.pendingPlans.set(planId, { text: planText }); + + const planEvent: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item: { + type: "plan", + planId, + text: planText, + actions: [{ id: "implement", label: "Implement", variant: "primary" }], + }, + }; + await this.appendHistoryEvent(planEvent); + this.notifySubscribers(planEvent); + + const completed: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: { inputTokens: 1, outputTokens: 1 }, + }; + await this.appendHistoryEvent(completed); + this.notifySubscribers(completed); + } + + private async emitPlanFileTurn(): Promise { + const planEvent: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item: { + type: "plan", + planId: "plan-file:.paseo/plans/fake.md", + text: "# File plan\n\n- From disk", + }, + }; + await this.appendHistoryEvent(planEvent); + this.notifySubscribers(planEvent); + + const completed: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: { inputTokens: 1, outputTokens: 1 }, + }; + await this.appendHistoryEvent(completed); + this.notifySubscribers(completed); + } + private async resolveToolPermission(tool: { name: string; input?: Record; @@ -729,6 +781,16 @@ class FakeAgentSession implements AgentSession { return; } + if (textPrompt.toLowerCase().includes("emit an actionable plan")) { + await this.emitActionablePlanTurn(textPrompt); + return; + } + + if (textPrompt.toLowerCase().includes("emit a plan file")) { + await this.emitPlanFileTurn(); + return; + } + const tool = buildToolCallForPrompt(this.providerName, textPrompt); if (tool) { const returnedEarly = await this.emitToolCallTurn(tool, textPrompt); @@ -834,6 +896,20 @@ class FakeAgentSession implements AgentSession { this.permissionGate = null; } + async respondToPlan( + planId: string, + response: AgentPlanResponse, + ): Promise { + const pending = this.pendingPlans.get(planId); + if (!pending) { + throw new Error(`No pending fake plan with id '${planId}'`); + } + this.pendingPlans.delete(planId); + if (response.actionId === "implement") { + return { followUpPrompt: `Implement fake plan:\n${pending.text}` }; + } + } + describePersistence(): AgentPersistenceHandle | null { return buildPersistence( this.providerName, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index ea45becb0c..6d60cd74d5 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1043,6 +1043,8 @@ export class VoiceAssistantWebSocketServer { providersSnapshot: true, // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13. checkoutGithubSetAutoMerge: true, + // COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28. + firstClassPlans: true, // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. daemonStatusRpc: true, // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 506be11b0e..20a83d385f 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -73,6 +73,18 @@ const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({ capabilities: LegacyAgentCapabilityFlagsSchema, }); +const LegacyPlanToolCallSchema = z.object({ + type: z.literal("tool_call"), + callId: z.string(), + name: z.string(), + status: z.enum(["running", "completed", "failed", "canceled"]), + error: z.unknown().nullable(), + detail: z.object({ + type: z.literal("plan"), + text: z.string(), + }), +}); + interface SessionInternals { handleFetchAgentTimelineRequest: ( message: Extract< @@ -232,6 +244,16 @@ function createSessionForWireCompatTest(options?: { timestamp: "2026-05-02T00:00:00.200Z", item: { type: "assistant_message", text: "done" }, }, + { + seq: 4, + timestamp: "2026-05-02T00:00:00.300Z", + item: { + type: "plan", + planId: "plan-1", + text: "# Plan\n\n- Do the thing", + actions: [{ id: "implement", label: "Implement", variant: "primary" }], + }, + }, ]; const session = new Session({ @@ -367,6 +389,38 @@ describe("wire compatibility", () => { expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge"); }); + test("downgrades plan timeline items for clients that do not declare the capability", async () => { + const response = await emitTimelineResponse(); + + const entry = response.payload.entries.find((item) => item.seqStart === 4); + expect(entry?.item).toEqual({ + type: "tool_call", + callId: "plan-1", + name: "Plan", + status: "completed", + error: null, + detail: { + type: "plan", + text: "# Plan\n\n- Do the thing", + }, + }); + expect(() => LegacyPlanToolCallSchema.parse(entry?.item)).not.toThrow(); + }); + + test("preserves plan timeline items for clients that declare the capability", async () => { + const response = await emitTimelineResponse({ + [CLIENT_CAPS.firstClassPlans]: true, + }); + + const entry = response.payload.entries.find((item) => item.seqStart === 4); + expect(entry?.item).toEqual({ + type: "plan", + planId: "plan-1", + text: "# Plan\n\n- Do the thing", + actions: [{ id: "implement", label: "Implement", variant: "primary" }], + }); + }); + test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => { const parsed = LegacySubAgentToolCallSchema.parse({ type: "tool_call",