Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 71ce3b434e Normalize plan file ids across platforms 2026-05-31 10:05:24 +07:00
Mohamed Boudra bcd1f28f9a Surface plans as first-class timeline items 2026-05-31 10:05:24 +07:00
Mohamed Boudra 41cb1af036 Add handle pan-down fallback to bottom-sheet reopen e2e
The "model selector can open, close, reopen, and close again" test still flaked
on loaded CI runners even after re-tapping the backdrop: the model-selector
sheet re-renders as its model list settles, and Gorhom drops backdrop presses
during that churn, so the tap never dismisses within the retry window.

Fall back to dragging the sheet handle down, which drives Gorhom's pan-to-close
directly and is unaffected by the backdrop churn (mirrors the resilient close in
e2e/helpers/app.ts). The backdrop tap remains the primary path, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged. Verified locally by forcing the fallback (backdrop tap
disabled): the handle drag closes the sheet on its own.
2026-05-31 08:38:46 +07:00
Mohamed Boudra 17aa957d3e Stabilize bottom-sheet reopen e2e by retrying the backdrop tap (#1238)
The "model selector can open, close, reopen, and close again" test fails on
loaded CI runners: a single backdrop tap that lands while the sheet is still
present-animating is dropped (Gorhom ignores backdrop presses until the sheet
settles at its snap point), so the sheet never dismisses and the backdrop stays
visible. The reopen behaviour itself is correct — the visibility tracker is
unit-tested and the spec passes locally (3x), so this is test-interaction
fragility, not a product regression.

Re-tap the backdrop until the sheet dismisses, mirroring the resilient close
loop already used in e2e/helpers/app.ts. It stays a pure backdrop dismissal (no
Escape/pan fallback) so the real close path is still exercised, and the
post-close "stays hidden" guard that protects the dismiss-then-re-present
regression is unchanged.
2026-05-30 22:59:02 +08:00
paseo-ai[bot] 2c756e9a8a fix: update lockfile signatures and Nix hash [skip ci] 2026-05-30 02:53:33 +00:00
28 changed files with 1322 additions and 187 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-B6xcv2QfqpmvZCwYZ34pF2HWu3Icjl3bylHcQr8lo8g= sha256-ngMXsN0UDo4ldTMQgDQ4r6XfAvwnwh4hzglIcmGYu9E=
+36 -5
View File
@@ -36,18 +36,49 @@ function bottomSheetBackdrop(page: Page) {
return page.getByRole("button", { name: "Bottom sheet backdrop" }).first(); return page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
} }
function bottomSheetHandle(page: Page) {
return page.getByRole("slider", { name: "Bottom sheet handle" }).first();
}
async function expectBottomSheetOpen(page: Page) { async function expectBottomSheetOpen(page: Page) {
await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 }); await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 });
} }
async function closeBottomSheetWithBackdrop(page: Page) { async function closeBottomSheetWithBackdrop(page: Page) {
const box = await bottomSheetBackdrop(page).boundingBox(); const backdrop = bottomSheetBackdrop(page);
expect(box).not.toBeNull(); const handle = bottomSheetHandle(page);
await page.mouse.click(box!.x + box!.width / 2, box!.y + 24); // Tapping the backdrop is the close path under test, but on a loaded CI runner
await expect(bottomSheetBackdrop(page)).not.toBeVisible({ timeout: 10_000 }); // the model-selector sheet re-renders as its model list settles and Gorhom
// drops backdrop presses during that churn — so a tap (even retried) can fail
// to dismiss. Tap the backdrop first; if it survives, drag the handle down,
// which drives Gorhom's pan-to-close directly and is unaffected by the churn.
// The post-close guard below still protects the regression this test exists
// for: a sheet that dismisses, then re-presents.
await expect(async () => {
if (!(await backdrop.isVisible())) {
return;
}
const box = await backdrop.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + 24);
}
await page.waitForTimeout(150);
if (await backdrop.isVisible()) {
const handleBox = await handle.boundingBox();
if (handleBox) {
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 400, { steps: 8 });
await page.mouse.up();
}
}
await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
}).toPass({ timeout: 15_000 });
// Guard against the regression where the sheet starts dismissing, then re-presents. // Guard against the regression where the sheet starts dismissing, then re-presents.
await page.waitForTimeout(500); await page.waitForTimeout(500);
await expect(bottomSheetBackdrop(page)).not.toBeVisible({ timeout: 1_000 }); await expect(backdrop).not.toBeVisible({ timeout: 1_000 });
} }
async function openTabSwitcher(page: Page) { async function openTabSwitcher(page: Page) {
+92 -1
View File
@@ -41,6 +41,7 @@ import { PlanCard } from "@/components/plan-card";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { import type {
AgentPlanAction,
AgentPermissionAction, AgentPermissionAction,
AgentPermissionResponse, AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types"; } from "@getpaseo/protocol/agent-types";
@@ -113,6 +114,86 @@ function renderPendingPermissionsNode(input: {
); );
} }
function PlanTimelineCard({
item,
agentId,
client,
}: {
item: Extract<StreamItem, { kind: "plan" }>;
agentId: string;
client: DaemonClient | null;
}) {
const [respondingActionId, setRespondingActionId] = useState<string | null>(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 (
<View>
<PlanCard title="Plan" text={item.text} testID="timeline-plan-card" />
{actions.length > 0 ? (
<View style={permissionStyles.optionsContainer}>
{actions.map((action) => (
<PlanActionButton
key={action.id}
action={action}
respondingActionId={respondingActionId}
isResponding={respondToPlan.isPending}
onRespond={respondToPlan.mutate}
/>
))}
</View>
) : null}
</View>
);
}
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<AgentPermissionAction>(
() => ({
...action,
behavior: action.variant === "danger" ? "deny" : "allow",
}),
[action],
);
const handlePress = useCallback(() => onRespond(action), [action, onRespond]);
return (
<PermissionActionButton
action={permissionAction}
isRespondingAction={respondingActionId === action.id}
isResponding={isResponding}
isPrimary={action.variant === "primary"}
Icon={Icon}
testID={`plan-action-${action.id}`}
onPress={handlePress}
/>
);
}
function renderStreamItemWithTurnFooter(input: { function renderStreamItemWithTurnFooter(input: {
content: ReactNode; content: ReactNode;
layoutItem: StreamLayoutItem; layoutItem: StreamLayoutItem;
@@ -549,6 +630,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
case "todo_list": case "todo_list":
return <TodoListCard items={item.items} />; return <TodoListCard items={item.items} />;
case "plan":
return <PlanTimelineCard item={item} agentId={agentId} client={client} />;
case "compaction": case "compaction":
return ( return (
<CompactionMarker <CompactionMarker
@@ -562,7 +646,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return null; return null;
} }
}, },
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem], [
agentId,
client,
renderUserMessageItem,
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
],
); );
const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter; const bottomTurnFooterHost = streamLayout.auxiliaryTurnFooter;
+33
View File
@@ -99,6 +99,19 @@ function todoTimeline(items: { text: string; completed: boolean }[]): AgentStrea
}; };
} }
function planTimeline(): AgentStreamEventPayload {
return {
type: "timeline",
provider: "codex",
item: {
type: "plan",
planId: "plan-1",
text: "# Plan\n\n- Implement it",
actions: [{ id: "implement", label: "Implement", variant: "primary" }],
},
};
}
function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined { function findToolByCallId(state: StreamItem[], callId: string): AgentToolCallItem | undefined {
return state.find( return state.find(
(item): item is AgentToolCallItem => (item): item is AgentToolCallItem =>
@@ -693,6 +706,26 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(todos.items[1]?.completed, true); 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<StreamItem, { kind: "plan" }> => 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", () => { it("renders Claude TodoWrite as todo_list and suppresses tool call badge", () => {
const state = hydrateStreamState([ const state = hydrateStreamState([
{ {
+46 -1
View File
@@ -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 { AgentAttachment, AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AttachmentMetadata } from "@/attachments/types"; import type { AttachmentMetadata } from "@/attachments/types";
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers"; import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
@@ -48,6 +52,7 @@ export type StreamItem =
| AssistantMessageItem | AssistantMessageItem
| ThoughtItem | ThoughtItem
| ToolCallItem | ToolCallItem
| PlanItem
| TodoListItem | TodoListItem
| ActivityLogItem | ActivityLogItem
| CompactionItem; | CompactionItem;
@@ -168,6 +173,16 @@ export interface TodoListItem {
items: TodoEntry[]; items: TodoEntry[];
} }
export interface PlanItem {
kind: "plan";
id: string;
timestamp: Date;
provider: AgentProvider;
planId: string;
text: string;
actions?: AgentPlanAction[];
}
export type StreamUpdateSource = "live" | "canonical"; export type StreamUpdateSource = "live" | "canonical";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -653,6 +668,34 @@ function appendTodoList(
return [...state, entry]; 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( function reduceTimelineToolCall(
state: StreamItem[], state: StreamItem[],
event: Extract<AgentStreamEventPayload, { type: "timeline" }>, event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
@@ -774,6 +817,8 @@ function reduceTimelineEvent(
})); }));
return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp)); return finalizeActiveThoughts(appendTodoList(state, event.provider, items, timestamp));
} }
case "plan":
return finalizeActiveThoughts(appendPlan(state, event.provider, item, timestamp));
case "error": { case "error": {
const activity: ActivityLogItem = { const activity: ActivityLogItem = {
kind: "activity_log", kind: "activity_log",
@@ -255,6 +255,7 @@ test("advertises client capabilities in hello", async () => {
protocolVersion: 1, protocolVersion: 1,
capabilities: { capabilities: {
custom_mode_icons: true, custom_mode_icons: true,
first_class_plans: true,
reasoning_merge_enum: true, reasoning_merge_enum: true,
}, },
}); });
+38
View File
@@ -84,6 +84,7 @@ import type {
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPlanResponse,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentProvider, AgentProvider,
AgentSessionConfig, AgentSessionConfig,
@@ -363,6 +364,10 @@ type DictationFinishAcceptedPayload = Extract<
{ type: "dictation_stream_finish_accepted" } { type: "dictation_stream_finish_accepted" }
>["payload"]; >["payload"];
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type AgentPlanRespondPayload = Extract<
SessionOutboundMessage,
{ type: "agent.plan.respond.response" }
>["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"];
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"]; export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["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<AgentPlanRespondPayload> {
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 // Waiting / Streaming Helpers
// ============================================================================ // ============================================================================
@@ -4278,6 +4315,7 @@ export class DaemonClient {
protocolVersion: 1, protocolVersion: 1,
capabilities: { capabilities: {
[CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.firstClassPlans]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.reasoningMergeEnum]: true,
}, },
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
+20
View File
@@ -313,12 +313,32 @@ export interface CompactionTimelineItem {
preTokens?: number; 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 = export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string } | { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId?: string } | { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string } | { type: "reasoning"; text: string }
| ToolCallTimelineItem | ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] } | { type: "todo"; items: { text: string; completed: boolean }[] }
| PlanTimelineItem
| { type: "error"; message: string } | { type: "error"; message: string }
| CompactionTimelineItem; | CompactionTimelineItem;
@@ -1,4 +1,5 @@
export const CLIENT_CAPS = { export const CLIENT_CAPS = {
firstClassPlans: "first_class_plans",
reasoningMergeEnum: "reasoning_merge_enum", reasoningMergeEnum: "reasoning_merge_enum",
// COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to // COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to
// a closed enum and crash rendering unknown values; daemon downgrades icons // a closed enum and crash rendering unknown values; daemon downgrades icons
@@ -179,11 +179,13 @@ describe("checkout PR schemas", () => {
features: { features: {
providersSnapshot: true, providersSnapshot: true,
checkoutGithubSetAutoMerge: true, checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
}, },
}).features, }).features,
).toEqual({ ).toEqual({
providersSnapshot: true, providersSnapshot: true,
checkoutGithubSetAutoMerge: true, checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
}); });
}); });
}); });
+38
View File
@@ -153,6 +153,7 @@ import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentPlanAction,
ProviderStatus, ProviderStatus,
AgentRuntimeInfo, AgentRuntimeInfo,
AgentTimelineItem, AgentTimelineItem,
@@ -334,6 +335,12 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
}), }),
]); ]);
const AgentPlanActionSchema: z.ZodType<AgentPlanAction> = z.object({
id: z.string(),
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
});
export const AgentPermissionRequestPayloadSchema: z.ZodType< export const AgentPermissionRequestPayloadSchema: z.ZodType<
AgentPermissionRequest, AgentPermissionRequest,
z.ZodTypeDef, z.ZodTypeDef,
@@ -549,6 +556,12 @@ export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, z.ZodT
}), }),
), ),
}), }),
z.object({
type: z.literal("plan"),
planId: z.string(),
text: z.string(),
actions: z.array(AgentPlanActionSchema).optional(),
}),
z.object({ z.object({
type: z.literal("error"), type: z.literal("error"),
message: z.string(), message: z.string(),
@@ -1324,6 +1337,15 @@ export const AgentPermissionResponseMessageSchema = z.object({
response: AgentPermissionResponseSchema, response: AgentPermissionResponseSchema,
}); });
export const AgentPlanRespondRequestMessageSchema = z.object({
type: z.literal("agent.plan.respond.request"),
agentId: z.string(),
planId: z.string(),
actionId: z.string(),
feedback: z.string().optional(),
requestId: z.string(),
});
const CheckoutErrorCodeSchema = z.enum([ const CheckoutErrorCodeSchema = z.enum([
"NOT_GIT_REPO", "NOT_GIT_REPO",
"NOT_ALLOWED", "NOT_ALLOWED",
@@ -1901,6 +1923,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentFeatureRequestMessageSchema, SetAgentFeatureRequestMessageSchema,
AgentRewindRequestMessageSchema, AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema, AgentPermissionResponseMessageSchema,
AgentPlanRespondRequestMessageSchema,
CheckoutStatusRequestSchema, CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema, SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema, UnsubscribeCheckoutDiffRequestSchema,
@@ -2138,6 +2161,8 @@ export const ServerInfoStatusPayloadSchema = z
.object({ .object({
providersSnapshot: z.boolean().optional(), providersSnapshot: z.boolean().optional(),
checkoutGithubSetAutoMerge: z.boolean().optional(), checkoutGithubSetAutoMerge: z.boolean().optional(),
// COMPAT(firstClassPlans): added in v0.1.82, remove gate after 2026-11-28.
firstClassPlans: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18. // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(), daemonStatusRpc: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -2704,6 +2729,17 @@ export const SendAgentMessageResponseMessageSchema = z.object({
}), }),
}); });
export const AgentPlanRespondResponseMessageSchema = z.object({
type: z.literal("agent.plan.respond.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
planId: z.string(),
ok: z.boolean(),
error: z.string().nullable().optional(),
}),
});
export const WaitForFinishResponseMessageSchema = z.object({ export const WaitForFinishResponseMessageSchema = z.object({
type: z.literal("wait_for_finish_response"), type: z.literal("wait_for_finish_response"),
payload: z.object({ payload: z.object({
@@ -3693,6 +3729,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CancelAgentResponseMessageSchema, CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema, ClearAgentAttentionResponseMessageSchema,
SendAgentMessageResponseMessageSchema, SendAgentMessageResponseMessageSchema,
AgentPlanRespondResponseMessageSchema,
SetVoiceModeResponseMessageSchema, SetVoiceModeResponseMessageSchema,
DaemonGetStatusResponseSchema, DaemonGetStatusResponseSchema,
DaemonGetPairingOfferResponseSchema, DaemonGetPairingOfferResponseSchema,
@@ -4106,6 +4143,7 @@ export const WSHelloMessageSchema = z.object({
.object({ .object({
voice: z.boolean().optional(), voice: z.boolean().optional(),
pushNotifications: z.boolean().optional(), pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.firstClassPlans]: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(), [CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
}) })
@@ -19,6 +19,8 @@ import {
type AgentLaunchContext, type AgentLaunchContext,
type AgentSlashCommand, type AgentSlashCommand,
type AgentMode, type AgentMode,
type AgentPlanResponse,
type AgentPlanResult,
type AgentPermissionRequest, type AgentPermissionRequest,
type AgentPermissionResponse, type AgentPermissionResponse,
type AgentPermissionResult, type AgentPermissionResult,
@@ -1836,6 +1838,31 @@ export class AgentManager {
} }
} }
async respondToPlan(
agentId: string,
planId: string,
response: AgentPlanResponse,
): Promise<AgentPlanResult | void> {
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<boolean> { async cancelAgentRun(agentId: string): Promise<boolean> {
const agent = this.requireSessionAgent(agentId); const agent = this.requireSessionAgent(agentId);
const pendingRun = this.foregroundRuns.getPendingRun(agentId); const pendingRun = this.foregroundRuns.getPendingRun(agentId);
@@ -342,12 +342,27 @@ export interface CompactionTimelineItem {
preTokens?: number; 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 = export type AgentTimelineItem =
| { type: "user_message"; text: string; messageId?: string } | { type: "user_message"; text: string; messageId?: string }
| { type: "assistant_message"; text: string; messageId?: string } | { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string } | { type: "reasoning"; text: string }
| ToolCallTimelineItem | ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] } | { type: "todo"; items: { text: string; completed: boolean }[] }
| PlanTimelineItem
| { type: "error"; message: string } | { type: "error"; message: string }
| CompactionTimelineItem; | CompactionTimelineItem;
@@ -551,6 +566,15 @@ export interface AgentPermissionResult {
followUpPrompt?: AgentPromptInput; followUpPrompt?: AgentPromptInput;
} }
export interface AgentPlanResponse {
actionId: string;
feedback?: string;
}
export interface AgentPlanResult {
followUpPrompt?: AgentPromptInput;
}
export interface AgentSession { export interface AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly id: string | null; readonly id: string | null;
@@ -569,6 +593,7 @@ export interface AgentSession {
requestId: string, requestId: string,
response: AgentPermissionResponse, response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>; ): Promise<AgentPermissionResult | void>;
respondToPlan?(planId: string, response: AgentPlanResponse): Promise<AgentPlanResult | void>;
describePersistence(): AgentPersistenceHandle | null; describePersistence(): AgentPersistenceHandle | null;
interrupt(): Promise<void>; interrupt(): Promise<void>;
close(): Promise<void>; close(): Promise<void>;
@@ -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",
});
});
});
@@ -0,0 +1,70 @@
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(["/.paseo/plans/", "/.opencode/plans/"]);
export function isPlanFilePath(filePath: string): boolean {
const normalized = normalizePlanPath(filePath);
const ext = path.posix.extname(normalized).toLowerCase();
if (!PLAN_FILE_EXTENSIONS.has(ext)) {
return false;
}
const searchable = normalized.startsWith("/") ? normalized : `/${normalized}`;
return Array.from(PLAN_DIRECTORIES).some((dir) => searchable.includes(dir));
}
export async function planItemFromToolCall(params: {
item: ToolCallTimelineItem;
cwd: string;
homeDir: string;
}): Promise<AgentTimelineItem | null> {
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:${normalizePlanPath(detail.filePath)}`,
text: text.trim(),
};
}
function normalizePlanPath(filePath: string): string {
return path.posix.normalize(filePath.replace(/\\/g, "/"));
}
async function readPlanFile(
filePath: string,
cwd: string,
homeDir: string,
): Promise<string | null> {
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;
}
@@ -363,6 +363,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
setMode: (modeId) => inner.setMode(modeId), setMode: (modeId) => inner.setMode(modeId),
getPendingPermissions: () => inner.getPendingPermissions(), getPendingPermissions: () => inner.getPendingPermissions(),
respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response), respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response),
respondToPlan: inner.respondToPlan?.bind(inner),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()), describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
interrupt: () => inner.interrupt(), interrupt: () => inner.interrupt(),
close: () => inner.close(), close: () => inner.close(),
@@ -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 }))); const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
sdkQueryFactory.mockImplementation(() => queryMock); sdkQueryFactory.mockImplementation(() => queryMock);
@@ -1023,44 +1023,36 @@ test("plan approval exposes a resume-bypass action and can return to bypassPermi
{}, {},
); );
const requestEvent = events.find( const planEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> => (event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "permission_requested" && event.request.kind === "plan", event.type === "timeline" && event.item.type === "plan",
); );
expect(requestEvent).toBeDefined(); expect(planEvent).toBeDefined();
expect(requestEvent?.request.actions).toEqual([ expect(planEvent?.item.type === "plan" ? planEvent.item.actions : undefined).toEqual([
{ {
id: "reject", id: "reject",
label: "Reject", label: "Reject",
behavior: "deny",
variant: "danger", variant: "danger",
intent: "dismiss",
}, },
{ {
id: "implement", id: "implement",
label: "Implement", label: "Implement",
behavior: "allow",
variant: "primary", variant: "primary",
intent: "implement",
}, },
{ {
id: "implement_resume", id: "implement_resume",
label: "Implement with Bypass", label: "Implement with Bypass",
behavior: "allow",
variant: "secondary", variant: "secondary",
intent: "implement_resume",
}, },
]); ]);
if (!requestEvent) { if (!planEvent || planEvent.item.type !== "plan") {
throw new Error("Expected plan permission request"); throw new Error("Expected plan item");
} }
expect(session.getPendingPermissions()).toEqual([]);
await session.respondToPermission(requestEvent.request.id, { await session.respondToPlan?.(planEvent.item.planId, { actionId: "implement_resume" });
behavior: "allow",
selectedActionId: "implement_resume",
});
await expect(pendingResolution).resolves.toMatchObject({ await expect(pendingResolution).resolves.toMatchObject({
behavior: "allow", behavior: "allow",
@@ -57,6 +57,8 @@ import {
type AgentMetadata, type AgentMetadata,
type AgentMode, type AgentMode,
type AgentModelDefinition, type AgentModelDefinition,
type AgentPlanAction,
type AgentPlanResponse,
type AgentPermissionRequest, type AgentPermissionRequest,
type AgentPermissionRequestKind, type AgentPermissionRequestKind,
type AgentPermissionResponse, type AgentPermissionResponse,
@@ -897,6 +899,14 @@ function buildClaudePlanPermissionActions(
return actions; return actions;
} }
function buildClaudePlanActions(resumeMode: PermissionMode | null): AgentPlanAction[] {
return buildClaudePlanPermissionActions(resumeMode).map(({ id, label, variant }) => ({
id,
label,
variant,
}));
}
interface TimelineFragment { interface TimelineFragment {
kind: "assistant" | "reasoning"; kind: "assistant" | "reasoning";
text: string; text: string;
@@ -1584,6 +1594,7 @@ class ClaudeAgentSession implements AgentSession {
private toolUseIndexToId = new Map<number, string>(); private toolUseIndexToId = new Map<number, string>();
private toolUseInputBuffers = new Map<string, string>(); private toolUseInputBuffers = new Map<string, string>();
private pendingPermissions = new Map<string, PendingPermission>(); private pendingPermissions = new Map<string, PendingPermission>();
private pendingPlans = new Map<string, string>();
private activeForegroundTurnId: string | null = null; private activeForegroundTurnId: string | null = null;
private autonomousTurn: AutonomousTurnState | null = null; private autonomousTurn: AutonomousTurnState | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -1919,82 +1930,140 @@ class ClaudeAgentSession implements AgentSession {
} }
getPendingPermissions(): AgentPermissionRequest[] { 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<void> { 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<void> {
const pending = this.pendingPermissions.get(requestId); const pending = this.pendingPermissions.get(requestId);
if (!pending) { if (!pending) {
throw new Error(`No pending permission request with id '${requestId}'`); throw new Error(`No pending permission request with id '${requestId}'`);
} }
this.pendingPermissions.delete(requestId); this.pendingPermissions.delete(requestId);
this.clearPendingPlanForPermission(requestId);
pending.cleanup?.(); pending.cleanup?.();
if (response.behavior === "allow") { if (response.behavior === "allow") {
if (pending.request.kind === "plan") { await this.resolveAllowedPermission(pending, response);
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);
} else { } else {
if (pending.request.kind === "tool") { this.resolveDeniedPermission(pending, response);
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.pushEvent({ if (emitResolution) {
type: "permission_resolved", this.pushEvent({
provider: "claude", type: "permission_resolved",
requestId, provider: "claude",
resolution: response, requestId,
resolution: response,
});
}
}
private async resolveAllowedPermission(
pending: PendingPermission,
response: Extract<AgentPermissionResponse, { behavior: "allow" }>,
): Promise<void> {
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<AgentPermissionResponse, { behavior: "deny" }>,
): 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<void> {
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 { describePersistence(): AgentPersistenceHandle | null {
if (this.persistence) { if (this.persistence) {
return this.persistence; return this.persistence;
@@ -3774,11 +3843,26 @@ class ClaudeAgentSession implements AgentSession {
metadata: Object.keys(metadata).length ? metadata : undefined, metadata: Object.keys(metadata).length ? metadata : undefined,
}; };
this.pushEvent({ if (kind === "plan" && typeof input.plan === "string") {
type: "permission_requested", const planId = `plan-${randomUUID()}`;
provider: "claude", this.pendingPlans.set(planId, requestId);
request, 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<PermissionResult>((resolve, reject) => { return await new Promise<PermissionResult>((resolve, reject) => {
const cleanupFns: Array<() => void> = []; const cleanupFns: Array<() => void> = [];
@@ -3795,6 +3879,11 @@ class ClaudeAgentSession implements AgentSession {
const abortHandler = () => { const abortHandler = () => {
this.pendingPermissions.delete(requestId); this.pendingPermissions.delete(requestId);
for (const [planId, pendingRequestId] of this.pendingPlans) {
if (pendingRequestId === requestId) {
this.pendingPlans.delete(planId);
}
}
cleanup(); cleanup();
reject(new Error("Permission request aborted")); reject(new Error("Permission request aborted"));
}; };
@@ -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({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, featureValues: { plan_mode: true, fast_mode: true },
}); });
@@ -2018,27 +2018,20 @@ describe("Codex app-server provider", () => {
), ),
).toBe(false); ).toBe(false);
expect(events.at(-2)).toEqual({ expect(events.at(-2)).toEqual({
type: "permission_requested", type: "timeline",
provider: "codex", provider: "codex",
turnId: "test-turn", turnId: "test-turn",
request: expect.objectContaining({ item: expect.objectContaining({
provider: "codex", type: "plan",
name: "CodexPlanApproval", text: "- Inspect the existing auth flow\n- Implement the button behavior",
kind: "plan",
title: "Plan",
input: {
plan: "- Inspect the existing auth flow\n- Implement the button behavior",
},
actions: [ actions: [
expect.objectContaining({ expect.objectContaining({
id: "reject", id: "reject",
label: "Reject", label: "Reject",
behavior: "deny",
}), }),
expect.objectContaining({ expect.objectContaining({
id: "implement", id: "implement",
label: "Implement", label: "Implement",
behavior: "allow",
}), }),
], ],
}), }),
@@ -2082,16 +2075,12 @@ describe("Codex app-server provider", () => {
}), }),
); );
expect(events.at(-2)).toEqual({ expect(events.at(-2)).toEqual({
type: "permission_requested", type: "timeline",
provider: "codex", provider: "codex",
turnId: "test-turn", turnId: "test-turn",
request: expect.objectContaining({ item: expect.objectContaining({
provider: "codex", type: "plan",
name: "CodexPlanApproval", text: "- Inspect README\n- Add a short note",
kind: "plan",
input: {
plan: "- 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({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, featureValues: { plan_mode: true, fast_mode: true },
}); });
@@ -2486,19 +2475,16 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null }, turn: { status: "completed", error: null },
}); });
const request = events.find( const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> => (event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "permission_requested" && event.request.kind === "plan", event.type === "timeline" && event.item.type === "plan",
); );
expect(request).toBeDefined(); expect(plan).toBeDefined();
if (!request) { if (!plan || plan.item.type !== "plan") {
throw new Error("Expected synthetic plan approval permission"); throw new Error("Expected plan item");
} }
const result = await session.respondToPermission(request.request.id, { const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
behavior: "allow",
selectedActionId: "implement",
});
expect(asInternals(session).serviceTier).toBe("fast"); expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(false); expect(asInternals(session).planModeEnabled).toBe(false);
@@ -2512,18 +2498,10 @@ describe("Codex app-server provider", () => {
expect(result!.followUpPrompt).toEqual( expect(result!.followUpPrompt).toEqual(
expect.stringContaining("The user approved the plan. Implement it now."), expect.stringContaining("The user approved the plan. Implement it now."),
); );
expect(events.at(-1)).toEqual({ expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_resolved" }));
type: "permission_resolved",
provider: "codex",
requestId: request.request.id,
resolution: {
behavior: "allow",
selectedActionId: "implement",
},
});
}); });
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({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: false }, featureValues: { plan_mode: true, fast_mode: false },
}); });
@@ -2540,19 +2518,16 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null }, turn: { status: "completed", error: null },
}); });
const request = events.find( const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> => (event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "permission_requested" && event.request.kind === "plan", event.type === "timeline" && event.item.type === "plan",
); );
expect(request).toBeDefined(); expect(plan).toBeDefined();
if (!request) { if (!plan || plan.item.type !== "plan") {
throw new Error("Expected synthetic plan approval permission"); throw new Error("Expected plan item");
} }
const result = await session.respondToPermission(request.request.id, { const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
behavior: "allow",
selectedActionId: "implement",
});
expect(asInternals(session).serviceTier).toBeNull(); expect(asInternals(session).serviceTier).toBeNull();
expect(asInternals(session).planModeEnabled).toBe(false); expect(asInternals(session).planModeEnabled).toBe(false);
@@ -2608,19 +2583,16 @@ describe("Codex app-server provider", () => {
turn: { status: "completed", error: null }, turn: { status: "completed", error: null },
}); });
const permissionRequest = events.find( const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> => (event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "permission_requested" && event.request.kind === "plan", event.type === "timeline" && event.item.type === "plan",
); );
expect(permissionRequest).toBeDefined(); expect(plan).toBeDefined();
if (!permissionRequest) { if (!plan || plan.item.type !== "plan") {
throw new Error("Expected synthetic plan approval permission"); throw new Error("Expected plan item");
} }
const result = await session.respondToPermission(permissionRequest.request.id, { const result = await session.respondToPlan?.(plan.item.planId, { actionId: "implement" });
behavior: "allow",
selectedActionId: "implement",
});
expect(result?.followUpPrompt).toEqual(expect.any(String)); expect(result?.followUpPrompt).toEqual(expect.any(String));
await session.startTurn(result!.followUpPrompt!); await session.startTurn(result!.followUpPrompt!);
@@ -8,6 +8,9 @@ import {
type AgentLaunchContext, type AgentLaunchContext,
type AgentMode, type AgentMode,
type AgentModelDefinition, type AgentModelDefinition,
type AgentPlanAction,
type AgentPlanResponse,
type AgentPlanResult,
type McpServerConfig, type McpServerConfig,
type AgentPersistenceHandle, type AgentPersistenceHandle,
type AgentPermissionRequest, type AgentPermissionRequest,
@@ -41,6 +44,7 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { z } from "zod"; import { z } from "zod";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { planItemFromToolCall } from "../plan-files.js";
import { composeSystemPromptParts } from "../system-prompt.js"; import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js"; import { curateAgentActivity } from "../activity-curator.js";
import { import {
@@ -941,6 +945,10 @@ function buildPlanPermissionActions(options?: {
return actions; return actions;
} }
function buildPlanActions(): AgentPlanAction[] {
return buildPlanPermissionActions().map(({ id, label, variant }) => ({ id, label, variant }));
}
function buildCodexPlanImplementationPrompt(planText: string): string { function buildCodexPlanImplementationPrompt(planText: string): string {
const normalizedPlan = normalizePlanMarkdown(planText); const normalizedPlan = normalizePlanMarkdown(planText);
if (!normalizedPlan) { if (!normalizedPlan) {
@@ -2925,6 +2933,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null; private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
private readonly userMessageTurnIndexes = new Map<string, number>(); private readonly userMessageTurnIndexes = new Map<string, number>();
private readonly userMessageTurnIds: string[] = []; private readonly userMessageTurnIds: string[] = [];
private pendingPlans = new Map<string, { text: string }>();
private pendingManualCompactionStarts = 0; private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">(); private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
// Codex can report one completed compaction through both channels: // Codex can report one completed compaction through both channels:
@@ -3187,30 +3196,36 @@ export class CodexAppServerAgentSession implements AgentSession {
}; };
} }
private emitSyntheticPlanApprovalRequest(planText: string): void { private emitPlanFileItemFromToolCall(item: ToolCallTimelineItem): void {
const requestId = `permission-${randomUUID()}`; void planItemFromToolCall({ item, cwd: this.config.cwd, homeDir: homedir() })
const request: AgentPermissionRequest = { .then((planItem) => {
id: requestId, if (planItem) {
provider: CODEX_PROVIDER, this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: planItem });
name: "CodexPlanApproval", }
kind: "plan", return undefined;
title: "Plan", })
description: "Review the proposed plan before implementation starts.", .catch((error) => {
input: { plan: planText }, this.logger.debug({ error, callId: item.callId }, "Failed to emit plan file item");
actions: buildPlanPermissionActions(), });
metadata: { }
planText,
source: "codex_plan_approval",
},
};
this.pendingPermissions.set(requestId, request); private emitPlanApprovalItem(planText: string): void {
this.pendingPermissionHandlers.set(requestId, { const text = normalizePlanMarkdown(planText);
resolve: () => undefined, if (!text) {
kind: "plan", return;
planText, }
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: {} }); pending.resolve({ answers: {} });
} }
async respondToPlan(
planId: string,
response: AgentPlanResponse,
): Promise<AgentPlanResult | void> {
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: { private handlePlanPermissionResponse(params: {
requestId: string; requestId: string;
response: AgentPermissionResponse; response: AgentPermissionResponse;
@@ -4569,7 +4607,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" }); this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else { } else {
if (this.planModeEnabled && this.latestPlanResult?.text) { if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text); this.emitPlanApprovalItem(this.latestPlanResult.text);
} }
this.emitEvent({ this.emitEvent({
type: "turn_completed", type: "turn_completed",
@@ -4883,6 +4921,9 @@ export class CodexAppServerAgentSession implements AgentSession {
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
} }
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem.type === "tool_call") {
this.emitPlanFileItemFromToolCall(timelineItem);
}
if (timelineItem.type === "assistant_message") { if (timelineItem.type === "assistant_message") {
this.pendingAssistantMessageBoundary = true; this.pendingAssistantMessageBoundary = true;
} }
@@ -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 cwd = tmpCwd();
const client = new CodexAppServerAgentClient(createTestLogger()); const client = new CodexAppServerAgentClient(createTestLogger());
@@ -50,18 +50,16 @@ describe("Codex app-server provider (real) plan mode", () => {
}), }),
); );
const planCall = result.timeline.find( const planItem = result.timeline.find((item) => item.type === "plan");
(item) => item.type === "tool_call" && item.detail.type === "plan",
);
expect(planCall).toBeDefined(); expect(planItem).toBeDefined();
if (!planCall || planCall.type !== "tool_call" || planCall.detail.type !== "plan") { if (!planItem || planItem.type !== "plan") {
throw new Error("Expected a plan tool call"); throw new Error("Expected a normalized plan item");
} }
expect(planCall.detail.text).toContain("Login"); expect(planItem.text).toContain("Login");
expect(planCall.detail.text).toContain("- "); expect(planItem.text).toContain("- ");
expect(result.finalText).toBe(planCall.detail.text); expect(planItem.actions?.some((action) => action.id === "implement")).toBe(true);
} finally { } finally {
await session.close(); await session.close();
} }
@@ -57,6 +57,7 @@ import {
resolveProviderLaunch, resolveProviderLaunch,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "../provider-launch-config.js"; } from "../provider-launch-config.js";
import { isPlanFilePath, planItemFromToolCall } from "../plan-files.js";
import { withTimeout } from "../../../utils/promise-timeout.js"; import { withTimeout } from "../../../utils/promise-timeout.js";
import { execCommand } from "../../../utils/spawn.js"; import { execCommand } from "../../../utils/spawn.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
@@ -2154,6 +2155,22 @@ function appendOpenCodeToolCallTimelineItem(
provider: "opencode", provider: "opencode",
item: timelineItem, 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) { if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) {
flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events); flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events);
} }
@@ -3213,9 +3230,28 @@ class OpenCodeAgentSession implements AgentSession {
return; return;
} }
this.notifySubscribers(e, turnId); 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( private finishForegroundTurn(
event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>, event: Extract<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
turnId: string, turnId: string,
@@ -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<PlanTimelineItem> {
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);
});
@@ -63,6 +63,49 @@ describe("serializeAgentStreamEvent", () => {
expect(serialized.item.messageId).toBe("m1"); 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", () => { test("passes canonical tool_call payloads through unchanged", () => {
const event: AgentStreamEvent = { const event: AgentStreamEvent = {
type: "timeline", type: "timeline",
+97 -2
View File
@@ -68,6 +68,7 @@ import { ensureAgentLoaded } from "./agent/agent-loading.js";
import { import {
formatSystemNotificationPrompt, formatSystemNotificationPrompt,
sendPromptToAgent, sendPromptToAgent,
startAgentRun,
waitForAgentRunStartWithTimeout, waitForAgentRunStartWithTimeout,
unarchiveAgentState, unarchiveAgentState,
} from "./agent/agent-prompt.js"; } from "./agent/agent-prompt.js";
@@ -142,6 +143,8 @@ import {
type AgentPromptInput, type AgentPromptInput,
type AgentRunOptions, type AgentRunOptions,
type AgentSessionConfig, type AgentSessionConfig,
type AgentStreamEvent,
type AgentTimelineItem,
type ProviderSnapshotEntry, type ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js";
@@ -716,6 +719,45 @@ function parseClientCapabilities(
return new Set(result); return new Set(result);
} }
function projectTimelineItemForClient(
item: AgentTimelineItem,
capabilities: ReadonlySet<ClientCapability>,
): 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<ClientCapability>,
): 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. * Session represents a single connected client session.
* It owns all state management, orchestration logic, and message processing. * 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) { if (!serializedEvent) {
return; return;
} }
@@ -1740,6 +1786,7 @@ export class Session {
const promise = const promise =
this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentPlanMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ?? this.dispatchCheckoutMessage(msg) ??
@@ -1751,6 +1798,13 @@ export class Session {
if (promise) await promise; if (promise) await promise;
} }
private dispatchAgentPlanMessage(msg: SessionInboundMessage): Promise<void> | undefined {
if (msg.type === "agent.plan.respond.request") {
return this.handleAgentPlanRespondRequest(msg);
}
return undefined;
}
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined { private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) { switch (msg.type) {
case "voice_audio_chunk": case "voice_audio_chunk":
@@ -4561,6 +4615,47 @@ export class Session {
} }
} }
private async handleAgentPlanRespondRequest(
msg: Extract<SessionInboundMessage, { type: "agent.plan.respond.request" }>,
): Promise<void> {
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( private async handleCheckoutStatusRequest(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>, msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
): Promise<void> { ): Promise<void> {
@@ -7565,7 +7660,7 @@ export class Session {
hasNewer, hasNewer,
entries: entries.map((entry) => ({ entries: entries.map((entry) => ({
provider: snapshot.provider, provider: snapshot.provider,
item: entry.item, item: projectTimelineItemForClient(entry.item, this.clientCapabilities),
timestamp: entry.timestamp, timestamp: entry.timestamp,
seqStart: entry.seqStart, seqStart: entry.seqStart,
seqEnd: entry.seqEnd, seqEnd: entry.seqEnd,
@@ -10,6 +10,8 @@ import type {
AgentLaunchContext, AgentLaunchContext,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentPlanResponse,
AgentPlanResult,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentPromptInput, AgentPromptInput,
AgentRunOptions, AgentRunOptions,
@@ -304,6 +306,7 @@ class FakeAgentSession implements AgentSession {
private memoryMarker: string | null = null; private memoryMarker: string | null = null;
private pendingPermissions: AgentPermissionRequest[] = []; private pendingPermissions: AgentPermissionRequest[] = [];
private permissionGate: Deferred<AgentPermissionResponse> | null = null; private permissionGate: Deferred<AgentPermissionResponse> | null = null;
private pendingPlans = new Map<string, { text: string }>();
private readonly historyPath: string; private readonly historyPath: string;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private nextTurnOrdinal = 0; private nextTurnOrdinal = 0;
@@ -522,6 +525,55 @@ class FakeAgentSession implements AgentSession {
this.notifySubscribers(completed); this.notifySubscribers(completed);
} }
private async emitActionablePlanTurn(text: string): Promise<void> {
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<void> {
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: { private async resolveToolPermission(tool: {
name: string; name: string;
input?: Record<string, unknown>; input?: Record<string, unknown>;
@@ -729,6 +781,16 @@ class FakeAgentSession implements AgentSession {
return; 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); const tool = buildToolCallForPrompt(this.providerName, textPrompt);
if (tool) { if (tool) {
const returnedEarly = await this.emitToolCallTurn(tool, textPrompt); const returnedEarly = await this.emitToolCallTurn(tool, textPrompt);
@@ -834,6 +896,20 @@ class FakeAgentSession implements AgentSession {
this.permissionGate = null; this.permissionGate = null;
} }
async respondToPlan(
planId: string,
response: AgentPlanResponse,
): Promise<AgentPlanResult | void> {
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 { describePersistence(): AgentPersistenceHandle | null {
return buildPersistence( return buildPersistence(
this.providerName, this.providerName,
@@ -1043,6 +1043,8 @@ export class VoiceAssistantWebSocketServer {
providersSnapshot: true, providersSnapshot: true,
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13. // COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
checkoutGithubSetAutoMerge: true, 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. // COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true, daemonStatusRpc: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23. // COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
@@ -73,6 +73,18 @@ const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
capabilities: LegacyAgentCapabilityFlagsSchema, 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 { interface SessionInternals {
handleFetchAgentTimelineRequest: ( handleFetchAgentTimelineRequest: (
message: Extract< message: Extract<
@@ -232,6 +244,16 @@ function createSessionForWireCompatTest(options?: {
timestamp: "2026-05-02T00:00:00.200Z", timestamp: "2026-05-02T00:00:00.200Z",
item: { type: "assistant_message", text: "done" }, 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({ const session = new Session({
@@ -367,6 +389,38 @@ describe("wire compatibility", () => {
expect(currentParsed.payload.entries[0]?.collapsed).toContain("reasoning_merge"); 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", () => { test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
const parsed = LegacySubAgentToolCallSchema.parse({ const parsed = LegacySubAgentToolCallSchema.parse({
type: "tool_call", type: "tool_call",