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();
}
function bottomSheetHandle(page: Page) {
return page.getByRole("slider", { name: "Bottom sheet handle" }).first();
}
async function expectBottomSheetOpen(page: Page) {
await expect(bottomSheetBackdrop(page)).toBeVisible({ timeout: 10_000 });
}
async function closeBottomSheetWithBackdrop(page: Page) {
const box = await bottomSheetBackdrop(page).boundingBox();
expect(box).not.toBeNull();
await page.mouse.click(box!.x + box!.width / 2, box!.y + 24);
await expect(bottomSheetBackdrop(page)).not.toBeVisible({ timeout: 10_000 });
const backdrop = bottomSheetBackdrop(page);
const handle = bottomSheetHandle(page);
// Tapping the backdrop is the close path under test, but on a loaded CI runner
// 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.
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) {
+92 -1
View File
@@ -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<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: {
content: ReactNode;
layoutItem: StreamLayoutItem;
@@ -549,6 +630,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
case "todo_list":
return <TodoListCard items={item.items} />;
case "plan":
return <PlanTimelineCard item={item} agentId={agentId} client={client} />;
case "compaction":
return (
<CompactionMarker
@@ -562,7 +646,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return null;
}
},
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
[
agentId,
client,
renderUserMessageItem,
renderAssistantMessageItem,
renderThoughtItem,
renderToolCallItem,
],
);
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 {
return state.find(
(item): item is AgentToolCallItem =>
@@ -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<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", () => {
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 { 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<string, unknown> {
@@ -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<AgentStreamEventPayload, { type: "timeline" }>,
@@ -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",
@@ -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,
},
});
+38
View File
@@ -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<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
// ============================================================================
@@ -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 } : {}),
+20
View File
@@ -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;
@@ -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
@@ -179,11 +179,13 @@ describe("checkout PR schemas", () => {
features: {
providersSnapshot: true,
checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
},
}).features,
).toEqual({
providersSnapshot: true,
checkoutGithubSetAutoMerge: true,
firstClassPlans: true,
});
});
});
+38
View File
@@ -153,6 +153,7 @@ import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentPlanAction,
ProviderStatus,
AgentRuntimeInfo,
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<
AgentPermissionRequest,
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({
type: z.literal("error"),
message: z.string(),
@@ -1324,6 +1337,15 @@ export const AgentPermissionResponseMessageSchema = z.object({
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([
"NOT_GIT_REPO",
"NOT_ALLOWED",
@@ -1901,6 +1923,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentFeatureRequestMessageSchema,
AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema,
AgentPlanRespondRequestMessageSchema,
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
UnsubscribeCheckoutDiffRequestSchema,
@@ -2138,6 +2161,8 @@ export const ServerInfoStatusPayloadSchema = z
.object({
providersSnapshot: 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.
daemonStatusRpc: z.boolean().optional(),
// 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({
type: z.literal("wait_for_finish_response"),
payload: z.object({
@@ -3693,6 +3729,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema,
SendAgentMessageResponseMessageSchema,
AgentPlanRespondResponseMessageSchema,
SetVoiceModeResponseMessageSchema,
DaemonGetStatusResponseSchema,
DaemonGetPairingOfferResponseSchema,
@@ -4106,6 +4143,7 @@ export const WSHelloMessageSchema = z.object({
.object({
voice: z.boolean().optional(),
pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.firstClassPlans]: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
})
@@ -19,6 +19,8 @@ import {
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
type AgentPlanResponse,
type AgentPlanResult,
type AgentPermissionRequest,
type AgentPermissionResponse,
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> {
const agent = this.requireSessionAgent(agentId);
const pendingRun = this.foregroundRuns.getPendingRun(agentId);
@@ -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<AgentPermissionResult | void>;
respondToPlan?(planId: string, response: AgentPlanResponse): Promise<AgentPlanResult | void>;
describePersistence(): AgentPersistenceHandle | null;
interrupt(): 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),
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(),
@@ -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<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
const planEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
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",
@@ -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<number, string>();
private toolUseInputBuffers = new Map<string, string>();
private pendingPermissions = new Map<string, PendingPermission>();
private pendingPlans = new Map<string, string>();
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<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);
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<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 {
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<PermissionResult>((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"));
};
@@ -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<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
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<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
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<AgentStreamEvent, { type: "permission_requested" }> =>
event.type === "permission_requested" && event.request.kind === "plan",
const plan = events.find(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
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!);
@@ -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<string, number>();
private readonly userMessageTurnIds: string[] = [];
private pendingPlans = new Map<string, { text: string }>();
private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
// 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<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: {
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;
}
@@ -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();
}
@@ -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<AgentStreamEvent, { type: "turn_completed" | "turn_failed" | "turn_canceled" }>,
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");
});
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",
+97 -2
View File
@@ -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<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.
* 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<void> | undefined {
if (msg.type === "agent.plan.respond.request") {
return this.handleAgentPlanRespondRequest(msg);
}
return undefined;
}
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
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(
msg: Extract<SessionInboundMessage, { type: "checkout_status_request" }>,
): Promise<void> {
@@ -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,
@@ -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<AgentPermissionResponse> | null = null;
private pendingPlans = new Map<string, { text: string }>();
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<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: {
name: string;
input?: Record<string, unknown>;
@@ -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<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 {
return buildPersistence(
this.providerName,
@@ -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.
@@ -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",