From 682fc54778c5a83aadc3f24e02cd7006eb306bd1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 7 Apr 2026 15:43:14 +0700 Subject: [PATCH] feat: normalize plan approval across providers --- docs/plan-approval-normalization.md | 68 +++++ .../app/src/components/agent-stream-view.tsx | 148 ++++++---- .../src/server/agent/agent-management-mcp.ts | 3 + .../src/server/agent/agent-manager.test.ts | 207 ++++++++++++++ .../server/src/server/agent/agent-manager.ts | 57 +++- .../src/server/agent/agent-projections.ts | 2 + .../src/server/agent/agent-sdk-types.ts | 11 + .../server/src/server/agent/agent-storage.ts | 3 +- .../server/src/server/agent/mcp-server.ts | 3 + .../providers/claude-agent.redesign.test.ts | 76 +++++ .../server/agent/providers/claude-agent.ts | 68 ++++- .../providers/codex-app-server-agent.test.ts | 264 +++++++++++++++++- .../agent/providers/codex-app-server-agent.ts | 233 +++++++++++++++- .../shared/messages.stream-parsing.test.ts | 68 +++++ packages/server/src/shared/messages.ts | 10 + 15 files changed, 1137 insertions(+), 84 deletions(-) create mode 100644 docs/plan-approval-normalization.md diff --git a/docs/plan-approval-normalization.md b/docs/plan-approval-normalization.md new file mode 100644 index 000000000..182d15ec5 --- /dev/null +++ b/docs/plan-approval-normalization.md @@ -0,0 +1,68 @@ +# Plan Approval Normalization + +## Goal + +Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface. + +## Compatibility Constraints + +- Older clients must remain compatible with newer daemons. +- All new wire fields must be optional. +- Existing plan permissions without action metadata must still render and work. +- Existing question permissions must keep their current behavior. + +## Design + +### Shared abstraction + +Add optional permission action definitions to the shared permission request/response types. + +- Permission requests may include `actions`. +- Permission responses may include `selectedActionId`. +- `kind: "plan"` remains the normalized concept for plan approval. +- The UI renders actions from the permission request instead of hardcoding provider-specific buttons. + +### Claude + +Keep Claude's plan permission flow, but enrich it with explicit action definitions. + +- Always expose `Reject`. +- Always expose `Implement`. +- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with `. +- Resolve the selected action entirely inside `respondToPermission()`. + +### Codex + +Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result. + +- Emit a plan permission with `Reject` and `Implement` actions. +- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn. +- On `Reject`, resolve without starting a follow-up turn. +- Keep the implementation prompt and state transitions inside the Codex provider. + +### Manager and state sync + +After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks. + +- Refresh current mode +- Refresh pending permissions +- Refresh runtime info +- Refresh features +- Persist refreshed state + +### UI + +Render plan permissions through the existing plan card, but generate buttons from normalized permission actions. + +- If `actions` are absent, fall back to legacy buttons. +- Plan cards should use `Implement` as the default primary label. +- Do not add provider-specific rendering branches. + +## Verification + +1. Shared schema/type tests for optional `actions` and `selectedActionId` +2. App tests for generic plan-action rendering +3. Claude tests for third action when resuming from a more permissive mode +4. Codex tests for synthetic plan approval and automatic implementation follow-up +5. Manager tests for post-permission state refresh +6. `npm run typecheck` diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index fd17d4223..f99667d54 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -38,7 +38,10 @@ import { import { PlanCard } from "./plan-card"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; -import type { AgentPermissionResponse } from "@server/server/agent/agent-sdk-types"; +import type { + AgentPermissionAction, + AgentPermissionResponse, +} from "@server/server/agent/agent-sdk-types"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; import { useSessionStore } from "@/stores/session-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; @@ -694,6 +697,29 @@ function PermissionRequestCard({ const isPlanRequest = request.kind === "plan"; const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required"); const description = request.description ?? ""; + const resolvedActions = useMemo((): AgentPermissionAction[] => { + if (request.kind === "question") { + return []; + } + if (Array.isArray(request.actions) && request.actions.length > 0) { + return request.actions; + } + return [ + { + id: "reject", + label: "Deny", + behavior: "deny", + variant: "danger", + intent: "dismiss", + }, + { + id: "accept", + label: isPlanRequest ? "Implement" : "Accept", + behavior: "allow", + variant: "primary", + }, + ]; + }, [isPlanRequest, request]); const planMarkdown = useMemo(() => { if (!request) { @@ -734,11 +760,11 @@ function PermissionRequestCard({ isPending: isResponding, } = permissionMutation; - const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null); + const [respondingActionId, setRespondingActionId] = useState(null); useEffect(() => { resetPermissionMutation(); - setRespondingAction(null); + setRespondingActionId(null); }, [permission.request.id, resetPermissionMutation]); const handleResponse = useCallback( (response: AgentPermissionResponse) => { @@ -752,6 +778,24 @@ function PermissionRequestCard({ }, [permission.agentId, permission.request.id, respondToPermission], ); + const handleActionPress = useCallback( + (action: AgentPermissionAction) => { + setRespondingActionId(action.id); + if (action.behavior === "allow") { + handleResponse({ + behavior: "allow", + selectedActionId: action.id, + }); + return; + } + handleResponse({ + behavior: "deny", + selectedActionId: action.id, + message: "Denied by user", + }); + }, + [handleResponse], + ); if (request.kind === "question") { return ( @@ -778,64 +822,48 @@ function PermissionRequestCard({ !isMobile && permissionStyles.optionsContainerDesktop, ]} > - [ - permissionStyles.optionButton, - { - backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, - borderColor: theme.colors.borderAccent, - }, - pressed ? permissionStyles.optionButtonPressed : null, - ]} - onPress={() => { - setRespondingAction("deny"); - handleResponse({ - behavior: "deny", - message: "Denied by user", - }); - }} - disabled={isResponding} - > - {respondingAction === "deny" ? ( - - ) : ( - - - - Deny - - - )} - + {resolvedActions.map((action) => { + const isDanger = action.variant === "danger" || action.behavior === "deny"; + const isPrimary = action.variant === "primary"; + const isRespondingAction = respondingActionId === action.id; + const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted; + const iconColor = textColor; + const Icon = action.behavior === "allow" ? Check : X; + const testID = + action.behavior === "deny" + ? "permission-request-deny" + : action.id === "accept" || action.id === "implement" + ? "permission-request-accept" + : `permission-request-action-${action.id}`; - [ - permissionStyles.optionButton, - { - backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, - borderColor: theme.colors.borderAccent, - }, - pressed ? permissionStyles.optionButtonPressed : null, - ]} - onPress={() => { - setRespondingAction("accept"); - handleResponse({ behavior: "allow" }); - }} - disabled={isResponding} - > - {respondingAction === "accept" ? ( - - ) : ( - - - - Accept - - - )} - + return ( + [ + permissionStyles.optionButton, + { + backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1, + borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent, + }, + pressed ? permissionStyles.optionButtonPressed : null, + ]} + onPress={() => handleActionPress(action)} + disabled={isResponding} + > + {isRespondingAction ? ( + + ) : ( + + + + {action.label} + + + )} + + ); + })} ); diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 14086d973..001a5bc5b 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -171,6 +171,9 @@ function sanitizePermissionRequest( if (sanitized.suggestions === undefined) { delete sanitized.suggestions; } + if (sanitized.actions === undefined) { + delete sanitized.actions; + } if (sanitized.metadata === undefined) { delete sanitized.metadata; } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ed41c70b9..80161dbb8 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2890,6 +2890,213 @@ describe("AgentManager", () => { expect(updatedAgent?.currentModeId).toBe("acceptEdits"); }); + test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class RefreshingPermissionSession extends TestAgentSession { + private featureState: AgentFeature[] = [ + createFeature({ id: "fast_mode", label: "Fast", value: true }), + createFeature({ id: "plan_mode", label: "Plan", value: true }), + ]; + private modeId = "auto"; + private pending = [ + { + id: "perm-plan-1", + provider: "codex" as const, + name: "CodexPlanApproval", + kind: "plan" as const, + input: { plan: "- Implement the feature" }, + }, + ]; + + get features(): AgentFeature[] { + return this.featureState; + } + + override async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: "gpt-5.4", + modeId: this.modeId, + extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" }, + }; + } + + override async getCurrentMode() { + return this.modeId; + } + + override getPendingPermissions() { + return this.pending; + } + + override async respondToPermission(): Promise { + this.modeId = "auto"; + this.pending = []; + this.featureState = [ + createFeature({ id: "fast_mode", label: "Fast", value: false }), + createFeature({ id: "plan_mode", label: "Plan", value: false }), + ]; + } + } + + class RefreshingPermissionClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new RefreshingPermissionSession(config); + } + } + + const manager = new AgentManager({ + clients: { + codex: new RefreshingPermissionClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000133", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const agent = manager.getAgent(snapshot.id); + if (!agent) { + throw new Error("Expected managed agent"); + } + agent.pendingPermissions.set("perm-plan-1", { + id: "perm-plan-1", + provider: "codex", + name: "CodexPlanApproval", + kind: "plan", + input: { plan: "- Implement the feature" }, + }); + + await manager.respondToPermission(snapshot.id, "perm-plan-1", { + behavior: "allow", + selectedActionId: "implement", + }); + + const updated = manager.getAgent(snapshot.id); + expect(updated?.pendingPermissions.size).toBe(0); + expect(updated?.features).toEqual([ + createFeature({ id: "fast_mode", label: "Fast", value: false }), + createFeature({ id: "plan_mode", label: "Plan", value: false }), + ]); + expect(updated?.runtimeInfo).toMatchObject({ + model: "gpt-5.4", + extra: { collaborationMode: "Code" }, + }); + + const persisted = await storage.get(snapshot.id); + expect(persisted?.features).toEqual([ + createFeature({ id: "fast_mode", label: "Fast", value: false }), + createFeature({ id: "plan_mode", label: "Plan", value: false }), + ]); + }); + + test("respondToPermission emits refreshed state before permission_resolved", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-permission-order-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class OrderedPermissionSession extends TestAgentSession { + private featureState: AgentFeature[] = [ + createFeature({ id: "fast_mode", label: "Fast", value: true }), + ]; + private modeId = "plan"; + private pending = [ + { + id: "perm-order-1", + provider: "codex" as const, + name: "ExitPlanMode", + kind: "plan" as const, + input: { plan: "- Do the work" }, + }, + ]; + + get features(): AgentFeature[] { + return this.featureState; + } + + override async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: "gpt-5.4", + modeId: this.modeId, + }; + } + + override async getCurrentMode() { + return this.modeId; + } + + override getPendingPermissions() { + return this.pending; + } + + override async respondToPermission(): Promise { + this.pushEvent({ + type: "permission_resolved", + provider: this.provider, + requestId: "perm-order-1", + resolution: { behavior: "allow" }, + }); + this.modeId = "acceptEdits"; + this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })]; + this.pending = []; + } + } + + class OrderedPermissionClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new OrderedPermissionSession(config); + } + } + + const manager = new AgentManager({ + clients: { + codex: new OrderedPermissionClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000134", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + const seen: string[] = []; + manager.subscribe((event) => { + if ("agentId" in event && event.agentId !== snapshot.id) { + return; + } + if (event.type === "agent_state" && event.agent.id === snapshot.id) { + const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode"); + seen.push(`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`); + return; + } + if (event.type === "agent_stream" && event.event.type === "permission_resolved") { + seen.push(`resolved:${event.event.requestId}`); + } + }); + + await manager.respondToPermission(snapshot.id, "perm-order-1", { + behavior: "allow", + }); + + const refreshedStateIndex = seen.findIndex((entry) => entry === "state:acceptEdits:false"); + const resolvedIndex = seen.findIndex((entry) => entry === "resolved:perm-order-1"); + expect(refreshedStateIndex).toBeGreaterThanOrEqual(0); + expect(resolvedIndex).toBeGreaterThan(refreshedStateIndex); + }); + test("close during in-flight stream does not clear persistence sessionId", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 50e4efa5f..f95f29205 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -172,6 +172,11 @@ type ManagedAgentBase = { features?: AgentFeature[]; currentModeId: string | null; pendingPermissions: Map; + bufferedPermissionResolutions: Map< + string, + Extract + >; + inFlightPermissionResponses: Set; pendingReplacement: boolean; timeline: AgentTimelineItem[]; timelineRows: AgentTimelineRow[]; @@ -1437,18 +1442,31 @@ export class AgentManager { response: AgentPermissionResponse, ): Promise { const agent = this.requireAgent(agentId); - await agent.session.respondToPermission(requestId, response); - agent.pendingPermissions.delete(requestId); + agent.inFlightPermissionResponses.add(requestId); - // Update currentModeId - the session may have changed mode internally - // (e.g., plan approval changes mode from "plan" to "acceptEdits") try { - agent.currentModeId = await agent.session.getCurrentMode(); - } catch { - // Ignore errors from getCurrentMode - mode tracking is best effort - } + await agent.session.respondToPermission(requestId, response); + agent.pendingPermissions.delete(requestId); - this.emitState(agent); + try { + await this.refreshSessionState(agent); + } catch { + // Ignore refresh errors - state sync after permission approval is best effort. + } + + this.touchUpdatedAt(agent); + await this.persistSnapshot(agent); + this.emitState(agent); + + const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId); + if (bufferedResolution) { + agent.bufferedPermissionResolutions.delete(requestId); + this.dispatchStream(agent.id, bufferedResolution); + } + } finally { + agent.inFlightPermissionResponses.delete(requestId); + agent.bufferedPermissionResolutions.delete(requestId); + } } async cancelAgentRun(agentId: string): Promise { @@ -1799,6 +1817,8 @@ export class AgentManager { availableModes: [], currentModeId: null, pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), pendingReplacement: false, activeForegroundTurnId: null, foregroundTurnWaiters: new Set(), @@ -2011,6 +2031,7 @@ export class AgentManager { agent.pendingPermissions.clear(); } + this.syncFeaturesFromSession(agent); await this.refreshRuntimeInfo(agent); } @@ -2087,6 +2108,7 @@ export class AgentManager { } let timelineRow: AgentTimelineRow | null = null; + let shouldDispatchEvent = true; switch (event.type) { case "thread_started": @@ -2259,6 +2281,11 @@ export class AgentManager { break; case "permission_resolved": agent.pendingPermissions.delete(event.requestId); + if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) { + agent.bufferedPermissionResolutions.set(event.requestId, event); + shouldDispatchEvent = false; + break; + } this.emitState(agent); break; default: @@ -2270,7 +2297,7 @@ export class AgentManager { } // Skip dispatching individual stream events during history replay. - if (!options?.fromHistory) { + if (!options?.fromHistory && shouldDispatchEvent) { this.dispatchStream( agent.id, event, @@ -2365,9 +2392,7 @@ export class AgentManager { // Keep attention as an edge-triggered unread signal, not a level signal. this.checkAndSetAttention(agent); - if (agent.session?.features) { - agent.features = agent.session.features; - } + this.syncFeaturesFromSession(agent); this.dispatch({ type: "agent_state", @@ -2375,6 +2400,12 @@ export class AgentManager { }); } + private syncFeaturesFromSession(agent: ManagedAgent): void { + if ("session" in agent && agent.session?.features) { + agent.features = agent.session.features; + } + } + private checkAndSetAttention(agent: ManagedAgent): void { const previousStatus = this.previousStatuses.get(agent.id); const currentStatus = agent.lifecycle; diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 9e204aaad..deebedd7c 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -61,6 +61,7 @@ export function toStoredAgentRecord( lastModeId: agent.currentModeId ?? config?.modeId ?? null, config: config ?? null, runtimeInfo, + features: agent.features, persistence, requiresAttention: agent.attention.requiresAttention, attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null, @@ -166,6 +167,7 @@ function sanitizePendingPermissions( ...request, input: sanitizeMetadata(request.input), suggestions: sanitizeMetadataArray(request.suggestions), + actions: request.actions?.map((action) => ({ ...action })), metadata: sanitizeMetadata(request.metadata), })); } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 0ab82be09..795e375a0 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -330,6 +330,14 @@ export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" | export type AgentPermissionUpdate = AgentMetadata; +export type AgentPermissionAction = { + id: string; + label: string; + behavior: "allow" | "deny"; + variant?: "primary" | "secondary" | "danger"; + intent?: "implement" | "implement_resume" | "dismiss"; +}; + export type AgentPermissionRequest = { id: string; provider: AgentProvider; @@ -340,17 +348,20 @@ export type AgentPermissionRequest = { input?: AgentMetadata; detail?: ToolCallDetail; suggestions?: AgentPermissionUpdate[]; + actions?: AgentPermissionAction[]; metadata?: AgentMetadata; }; export type AgentPermissionResponse = | { behavior: "allow"; + selectedActionId?: string; updatedInput?: AgentMetadata; updatedPermissions?: AgentPermissionUpdate[]; } | { behavior: "deny"; + selectedActionId?: string; message?: string; interrupt?: boolean; }; diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index e7fce3d0c..ee28b2355 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { z } from "zod"; import type { Logger } from "pino"; -import { AgentStatusSchema } from "../messages.js"; +import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js"; import { toStoredAgentRecord } from "./agent-projections.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { AgentSessionConfig } from "./agent-sdk-types.js"; @@ -56,6 +56,7 @@ const STORED_AGENT_SCHEMA = z.object({ extra: z.record(z.unknown()).optional(), }) .optional(), + features: z.array(AgentFeatureSchema).optional(), persistence: PERSISTENCE_HANDLE_SCHEMA, requiresAttention: z.boolean().optional(), attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 60f20cf5a..a982bc483 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -233,6 +233,9 @@ function sanitizePermissionRequest( if (sanitized.suggestions === undefined) { delete sanitized.suggestions; } + if (sanitized.actions === undefined) { + delete sanitized.actions; + } if (sanitized.metadata === undefined) { delete sanitized.metadata; } diff --git a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts index 74bd40656..0f490c08a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts @@ -835,6 +835,82 @@ describe("ClaudeAgentSession redesign invariants", () => { } }); + test("plan approval exposes a resume-bypass action and can return to bypassPermissions", async () => { + const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined }))); + sdkQueryFactory.mockImplementation(() => queryMock); + + const session = await createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + try { + await session.setMode("bypassPermissions"); + await session.setMode("plan"); + + const internal = session as unknown as { + handlePermissionRequest: ( + toolName: string, + input: Record, + options: Record, + ) => Promise; + }; + + const pendingResolution = internal.handlePermissionRequest( + "ExitPlanMode", + { plan: "- Implement the approved plan" }, + {}, + ); + + const requestEvent = events.find( + (event): event is Extract => + event.type === "permission_requested" && event.request.kind === "plan", + ); + + expect(requestEvent).toBeDefined(); + expect(requestEvent?.request.actions).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"); + } + + await session.respondToPermission(requestEvent.request.id, { + behavior: "allow", + selectedActionId: "implement_resume", + }); + + await expect(pendingResolution).resolves.toMatchObject({ + behavior: "allow", + updatedInput: { plan: "- Implement the approved plan" }, + }); + expect(queryMock.setPermissionMode).toHaveBeenLastCalledWith("bypassPermissions"); + expect(await session.getCurrentMode()).toBe("bypassPermissions"); + } finally { + await session.close(); + } + }); + test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => { const session = await createSession(); const internal = session as unknown as { diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index f9940e8a7..364a8af77 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -48,6 +48,7 @@ import { } from "./diagnostic-utils.js"; import type { + AgentPermissionAction, AgentCapabilityFlags, AgentClient, AgentLaunchContext, @@ -670,6 +671,43 @@ function resolvePermissionKind( return "tool"; } +function getClaudeModeLabel(modeId: PermissionMode): string { + return DEFAULT_MODES.find((mode) => mode.id === modeId)?.label ?? modeId; +} + +function buildClaudePlanPermissionActions( + resumeMode: PermissionMode | null, +): AgentPermissionAction[] { + const actions: AgentPermissionAction[] = [ + { + id: "reject", + label: "Reject", + behavior: "deny", + variant: "danger", + intent: "dismiss", + }, + { + id: "implement", + label: "Implement", + behavior: "allow", + variant: "primary", + intent: "implement", + }, + ]; + + if (resumeMode === "bypassPermissions") { + actions.push({ + id: "implement_resume", + label: `Implement with ${getClaudeModeLabel(resumeMode)}`, + behavior: "allow", + variant: "secondary", + intent: "implement_resume", + }); + } + + return actions; +} + type TimelineFragment = { kind: "assistant" | "reasoning"; text: string; @@ -1212,6 +1250,7 @@ class ClaudeAgentSession implements AgentSession { private claudeSessionId: string | null; private persistence: AgentPersistenceHandle | null; private currentMode: PermissionMode; + private planResumeMode: PermissionMode | null = null; private availableModes: AgentMode[] = DEFAULT_MODES; private toolUseCache = new Map(); private toolUseIndexToId = new Map(); @@ -1273,6 +1312,9 @@ class ClaudeAgentSession implements AgentSession { } this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default"; + if (this.currentMode !== "plan") { + this.planResumeMode = this.currentMode; + } } get id(): string | null { @@ -1515,8 +1557,16 @@ class ClaudeAgentSession implements AgentSession { } const normalized = isPermissionMode(modeId) ? modeId : "default"; + const previousMode = this.currentMode; const query = await this.ensureQuery(); await query.setPermissionMode(normalized); + if (normalized === "plan") { + if (previousMode !== "plan") { + this.planResumeMode = previousMode; + } + } else { + this.planResumeMode = normalized; + } this.currentMode = normalized; } @@ -1563,13 +1613,22 @@ class ClaudeAgentSession implements AgentSession { if (response.behavior === "allow") { if (pending.request.kind === "plan") { - await this.setMode("acceptEdits"); + 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 }, + output: { + approved: true, + actionId: selectedActionId ?? "implement", + }, }), ); } @@ -2800,6 +2859,9 @@ class ClaudeAgentSession implements AgentSession { } this.availableModes = DEFAULT_MODES; this.currentMode = message.permissionMode; + if (this.currentMode !== "plan") { + this.planResumeMode = this.currentMode; + } this.persistence = null; if (message.model) { const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model); @@ -2907,6 +2969,8 @@ class ClaudeAgentSession implements AgentSession { input, detail: toolDetail, suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })), + actions: + kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined, metadata: Object.keys(metadata).length ? metadata : undefined, }; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index b40bbc6c4..813a201c1 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { existsSync, rmSync } from "node:fs"; import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js"; @@ -116,6 +116,107 @@ describe("Codex app-server provider", () => { }); }); + test("normalizes raw output schemas for Codex structured outputs", () => { + const input = { + type: "object", + properties: { + findings: { + type: "array", + items: { + type: "object", + properties: { + severity: { type: "string" }, + summary: { type: "string" }, + }, + required: ["severity"], + }, + }, + overall: { type: "string" }, + }, + required: ["overall"], + }; + + const normalized = __codexAppServerInternals.normalizeCodexOutputSchema(input); + + expect(normalized).toEqual({ + type: "object", + properties: { + findings: { + type: "array", + items: { + type: "object", + properties: { + severity: { type: "string" }, + summary: { type: "string" }, + }, + required: ["severity", "summary"], + additionalProperties: false, + }, + }, + overall: { type: "string" }, + }, + required: ["overall", "findings"], + additionalProperties: false, + }); + expect(input).toEqual({ + type: "object", + properties: { + findings: { + type: "array", + items: { + type: "object", + properties: { + severity: { type: "string" }, + summary: { type: "string" }, + }, + required: ["severity"], + }, + }, + overall: { type: "string" }, + }, + required: ["overall"], + }); + }); + + test("passes a normalized output schema to turn/start", async () => { + const session = createSession(); + const request = vi.fn(async (method: string) => { + if (method === "thread/loaded/list") { + return { data: ["test-thread"] }; + } + if (method === "turn/start") { + return {}; + } + throw new Error(`Unexpected request: ${method}`); + }); + + session.activeForegroundTurnId = null; + session.client = { request } as any; + + await session.startTurn("Return JSON", { + outputSchema: { + type: "object", + properties: { + summary: { type: "string" }, + }, + }, + }); + + const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start"); + expect(turnStartCall?.[1]).toEqual( + expect.objectContaining({ + outputSchema: { + type: "object", + properties: { + summary: { type: "string" }, + }, + required: ["summary"], + additionalProperties: false, + }, + }), + ); + }); + test("maps image prompt blocks to Codex localImage input", async () => { const input = await codexAppServerTurnInputFromPrompt( [ @@ -421,4 +522,165 @@ describe("Codex app-server provider", () => { }, }); }); + + test("emits a synthetic plan approval permission after a successful Codex plan turn", () => { + const session = createSession({ + featureValues: { plan_mode: true, fast_mode: true }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + (session as any).handleNotification("turn/started", { + turn: { id: "turn-plan-1" }, + }); + (session as any).handleNotification("turn/plan/updated", { + plan: [ + { step: "Inspect the existing auth flow", status: "completed" }, + { step: "Implement the button behavior", status: "pending" }, + ], + }); + (session as any).handleNotification("turn/completed", { + turn: { status: "completed", error: null }, + }); + + expect(events.at(-2)).toEqual({ + type: "permission_requested", + 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", + }, + actions: [ + expect.objectContaining({ + id: "reject", + label: "Reject", + behavior: "deny", + }), + expect.objectContaining({ + id: "implement", + label: "Implement", + behavior: "allow", + }), + ], + }), + }); + expect(events.at(-1)).toEqual({ + type: "turn_completed", + provider: "codex", + turnId: "test-turn", + usage: undefined, + }); + }); + + test("approving a synthetic Codex plan permission disables plan and fast mode and starts implementation", async () => { + const session = createSession({ + featureValues: { plan_mode: true, fast_mode: true }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const startTurnSpy = vi + .spyOn(session, "startTurn") + .mockResolvedValue({ turnId: "follow-up-turn" }); + + (session as any).handleNotification("turn/started", { + turn: { id: "turn-plan-2" }, + }); + (session as any).handleNotification("turn/plan/updated", { + plan: [{ step: "Implement the new flow", status: "pending" }], + }); + (session as any).handleNotification("turn/completed", { + turn: { status: "completed", error: null }, + }); + + const request = events.find( + (event): event is Extract => + event.type === "permission_requested" && event.request.kind === "plan", + ); + expect(request).toBeDefined(); + if (!request) { + throw new Error("Expected synthetic plan approval permission"); + } + + await session.respondToPermission(request.request.id, { + behavior: "allow", + selectedActionId: "implement", + }); + + expect((session as any).serviceTier).toBeNull(); + expect((session as any).planModeEnabled).toBe(false); + expect((session as any).config.featureValues).toEqual({ + plan_mode: false, + fast_mode: false, + }); + expect(startTurnSpy).toHaveBeenCalledWith( + 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", + }, + }); + }); + + test("failed synthetic Codex plan implementation keeps the permission pending for retry", async () => { + const session = createSession({ + featureValues: { plan_mode: true, fast_mode: true }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const startTurnSpy = vi + .spyOn(session, "startTurn") + .mockRejectedValueOnce(new Error("follow-up failed")); + + (session as any).handleNotification("turn/started", { + turn: { id: "turn-plan-retry" }, + }); + (session as any).handleNotification("turn/plan/updated", { + plan: [{ step: "Implement the retriable flow", status: "pending" }], + }); + (session as any).handleNotification("turn/completed", { + turn: { status: "completed", error: null }, + }); + + const request = events.find( + (event): event is Extract => + event.type === "permission_requested" && event.request.kind === "plan", + ); + expect(request).toBeDefined(); + if (!request) { + throw new Error("Expected synthetic plan approval permission"); + } + + await expect( + session.respondToPermission(request.request.id, { + behavior: "allow", + selectedActionId: "implement", + }), + ).rejects.toThrow("follow-up failed"); + + expect(startTurnSpy).toHaveBeenCalledTimes(1); + expect((session as any).planModeEnabled).toBe(true); + expect((session as any).config.featureValues).toEqual({ + plan_mode: true, + fast_mode: true, + }); + expect(session.getPendingPermissions()).toEqual([request.request]); + expect( + events.some( + (event) => + event.type === "permission_resolved" && event.requestId === request.request.id, + ), + ).toBe(false); + }); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 4f09c9b1d..141a05d96 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1,4 +1,5 @@ import type { + AgentPermissionAction, AgentCapabilityFlags, AgentClient, AgentFeature, @@ -64,6 +65,8 @@ const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000; const CODEX_PROVIDER = "codex" as const; const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments"; +const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX = + "The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked."; const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -143,6 +146,73 @@ function normalizeCodexModelLabel(displayName: string): string { return displayName.replace(/\bgpt\b/gi, "GPT"); } +function isSchemaRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isObjectSchemaNode(schema: Record): boolean { + const type = schema.type; + return ( + isSchemaRecord(schema.properties) || + type === "object" || + (Array.isArray(type) && type.includes("object")) + ); +} + +function normalizeCodexOutputSchemaNode( + schema: unknown, + path: string, +): unknown { + if (Array.isArray(schema)) { + return schema.map((entry, index) => normalizeCodexOutputSchemaNode(entry, `${path}[${index}]`)); + } + if (!isSchemaRecord(schema)) { + return schema; + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(schema)) { + normalized[key] = normalizeCodexOutputSchemaNode(value, `${path}.${key}`); + } + + if (!isObjectSchemaNode(normalized)) { + return normalized; + } + + if (normalized.additionalProperties === undefined) { + normalized.additionalProperties = false; + } else if (normalized.additionalProperties !== false) { + throw new Error( + `Codex structured outputs require ${path} to set additionalProperties to false for object schemas.`, + ); + } + + const properties = isSchemaRecord(normalized.properties) ? normalized.properties : null; + if (!properties) { + return normalized; + } + + const propertyKeys = Object.keys(properties); + const existingRequired = Array.isArray(normalized.required) + ? normalized.required.filter((entry): entry is string => typeof entry === "string") + : []; + normalized.required = Array.from(new Set([...existingRequired, ...propertyKeys])); + return normalized; +} + +function normalizeCodexOutputSchema(schema: unknown): Record { + if (!isSchemaRecord(schema)) { + throw new Error("Codex structured outputs require a JSON object schema."); + } + + const normalized = normalizeCodexOutputSchemaNode(schema, "$"); + if (!isSchemaRecord(normalized) || !isObjectSchemaNode(normalized)) { + throw new Error("Codex structured outputs require a root object schema."); + } + + return normalized; +} + type CodexConfiguredDefaults = { model?: string; thinkingOptionId?: string; @@ -769,6 +839,53 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC }; } +function buildPlanPermissionActions( + options?: { includeResumeAction?: boolean; resumeLabel?: string }, +): AgentPermissionAction[] { + const actions: AgentPermissionAction[] = [ + { + id: "reject", + label: "Reject", + behavior: "deny", + variant: "danger", + intent: "dismiss", + }, + { + id: "implement", + label: "Implement", + behavior: "allow", + variant: "primary", + intent: "implement", + }, + ]; + + if (options?.includeResumeAction && options.resumeLabel) { + actions.push({ + id: "implement_resume", + label: options.resumeLabel, + behavior: "allow", + variant: "secondary", + intent: "implement_resume", + }); + } + + return actions; +} + +function buildCodexPlanImplementationPrompt(planText: string): string { + const normalizedPlan = normalizePlanMarkdown(planText); + if (!normalizedPlan) { + return `${CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX} Make the required code changes and verify them.`; + } + + return [ + CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX, + "Approved plan:", + normalizedPlan, + "Carry out the work, make the necessary code changes, and verify the result.", + ].join("\n\n"); +} + type CodexQuestionOption = { label: string; description?: string; @@ -2299,8 +2416,9 @@ class CodexAppServerAgentSession implements AgentSession { string, { resolve: (value: unknown) => void; - kind: "command" | "file" | "question"; + kind: "command" | "file" | "question" | "plan"; questions?: CodexQuestionPrompt[]; + planText?: string; } >(); private resolvedPermissionRequests = new Set(); @@ -2319,6 +2437,7 @@ class CodexAppServerAgentSession implements AgentSession { private warnedInvalidNotificationPayloads = new Set(); private warnedIncompleteEditToolCallIds = new Set(); private latestUsage: AgentUsage | undefined; + private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null; private connected = false; private collaborationModes: Array<{ name: string; @@ -2502,6 +2621,79 @@ class CodexAppServerAgentSession implements AgentSession { this.resolvedCollaborationMode = this.resolveCollaborationMode(); } + private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void { + this.config.featureValues = { + ...(this.config.featureValues ?? {}), + [featureId]: value, + }; + + if (featureId === "fast_mode") { + this.serviceTier = value ? "fast" : null; + this.cachedRuntimeInfo = null; + return; + } + + this.planModeEnabled = value; + this.refreshResolvedCollaborationMode(); + this.cachedRuntimeInfo = null; + } + + private rememberPlanResult(item: ToolCallTimelineItem): void { + if (item.detail.type !== "plan") { + return; + } + + this.latestPlanResult = { + callId: item.callId, + text: item.detail.text, + turnId: this.currentTurnId, + }; + } + + 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", + }, + }; + + this.pendingPermissions.set(requestId, request); + this.pendingPermissionHandlers.set(requestId, { + resolve: () => undefined, + kind: "plan", + planText, + }); + this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); + } + + private async handleApprovedPlanPermission(params: { planText?: unknown }): Promise { + const planText = + typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : ""; + const previousPlanMode = this.planModeEnabled; + const previousFastMode = this.serviceTier === "fast"; + + this.applyFeatureValue("plan_mode", false); + this.applyFeatureValue("fast_mode", false); + + try { + await this.startTurn(buildCodexPlanImplementationPrompt(planText)); + } catch (error) { + this.applyFeatureValue("plan_mode", previousPlanMode); + this.applyFeatureValue("fast_mode", previousFastMode); + throw error; + } + } + private registerRequestHandlers(): void { if (!this.client) return; @@ -2804,7 +2996,7 @@ class CodexAppServerAgentSession implements AgentSession { params.cwd = this.config.cwd; } if (options?.outputSchema) { - params.outputSchema = options.outputSchema; + params.outputSchema = normalizeCodexOutputSchema(options.outputSchema); } if (this.config.systemPrompt?.trim()) { params.developerInstructions = this.config.systemPrompt.trim(); @@ -2899,14 +3091,11 @@ class CodexAppServerAgentSession implements AgentSession { async setFeature(featureId: string, value: unknown): Promise { if (featureId === "fast_mode") { - this.serviceTier = value ? "fast" : null; - this.cachedRuntimeInfo = null; + this.applyFeatureValue("fast_mode", Boolean(value)); return; } if (featureId === "plan_mode") { - this.planModeEnabled = Boolean(value); - this.refreshResolvedCollaborationMode(); - this.cachedRuntimeInfo = null; + this.applyFeatureValue("plan_mode", Boolean(value)); return; } throw new Error(`Unknown Codex feature: ${featureId}`); @@ -2922,6 +3111,26 @@ class CodexAppServerAgentSession implements AgentSession { throw new Error(`No pending Codex app-server permission request with id '${requestId}'`); } const pendingRequest = this.pendingPermissions.get(requestId) ?? null; + + if (pending.kind === "plan") { + if (response.behavior === "allow") { + await this.handleApprovedPlanPermission({ + planText: pending.planText ?? pendingRequest?.metadata?.planText, + }); + } + + this.pendingPermissionHandlers.delete(requestId); + this.pendingPermissions.delete(requestId); + this.resolvedPermissionRequests.add(requestId); + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId, + resolution: response, + }); + return; + } + this.pendingPermissionHandlers.delete(requestId); this.pendingPermissions.delete(requestId); this.resolvedPermissionRequests.add(requestId); @@ -3223,6 +3432,7 @@ class CodexAppServerAgentSession implements AgentSession { if (parsed.kind === "turn_started") { this.currentTurnId = parsed.turnId; + this.latestPlanResult = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); this.emittedExecCommandStartedCallIds.clear(); @@ -3244,6 +3454,9 @@ class CodexAppServerAgentSession implements AgentSession { } else if (parsed.status === "interrupted") { this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" }); } else { + if (this.planModeEnabled && this.latestPlanResult?.text) { + this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text); + } this.emitEvent({ type: "turn_completed", provider: CODEX_PROVIDER, @@ -3251,6 +3464,7 @@ class CodexAppServerAgentSession implements AgentSession { }); } this.activeForegroundTurnId = null; + this.latestPlanResult = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); this.emittedExecCommandStartedCallIds.clear(); @@ -3272,6 +3486,7 @@ class CodexAppServerAgentSession implements AgentSession { ), }); if (timelineItem) { + this.rememberPlanResult(timelineItem); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, @@ -3465,6 +3680,9 @@ class CodexAppServerAgentSession implements AgentSession { } } if (timelineItem.type === "tool_call") { + if (timelineItem.detail.type === "plan") { + this.rememberPlanResult(timelineItem); + } this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); } this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); @@ -4038,6 +4256,7 @@ export const __codexAppServerInternals = { mapCodexPatchNotificationToToolCall, planStepsToMarkdown, mapCodexPlanToToolCall, + normalizeCodexOutputSchema, normalizeCodexQuestionPrompts, toAgentUsage, threadItemToTimeline, diff --git a/packages/server/src/shared/messages.stream-parsing.test.ts b/packages/server/src/shared/messages.stream-parsing.test.ts index 01dc36799..c5698c901 100644 --- a/packages/server/src/shared/messages.stream-parsing.test.ts +++ b/packages/server/src/shared/messages.stream-parsing.test.ts @@ -139,6 +139,74 @@ describe("shared messages stream parsing", () => { } }); + it("parses optional permission actions and selectedActionId compatibly", () => { + const requestParsed = AgentStreamMessageSchema.parse({ + type: "agent_stream", + payload: { + agentId: "agent_live", + timestamp: "2026-02-08T20:10:00.000Z", + event: { + type: "permission_requested", + provider: "codex", + request: { + id: "perm-1", + provider: "codex", + name: "CodexPlanApproval", + kind: "plan", + input: { plan: "- step 1" }, + actions: [ + { + id: "reject", + label: "Reject", + behavior: "deny", + variant: "danger", + intent: "dismiss", + }, + { + id: "implement", + label: "Implement", + behavior: "allow", + variant: "primary", + intent: "implement", + }, + ], + }, + }, + }, + }); + + expect(requestParsed.payload.event.type).toBe("permission_requested"); + if (requestParsed.payload.event.type === "permission_requested") { + expect(requestParsed.payload.event.request.actions).toHaveLength(2); + expect(requestParsed.payload.event.request.actions?.[1]?.label).toBe("Implement"); + } + + const resolutionParsed = AgentStreamMessageSchema.parse({ + type: "agent_stream", + payload: { + agentId: "agent_live", + timestamp: "2026-02-08T20:10:01.000Z", + event: { + type: "permission_resolved", + provider: "claude", + requestId: "perm-1", + resolution: { + behavior: "allow", + selectedActionId: "implement_resume", + }, + }, + }, + }); + + expect(resolutionParsed.payload.event.type).toBe("permission_resolved"); + if (resolutionParsed.payload.event.type === "permission_resolved") { + expect(resolutionParsed.payload.event.resolution).toEqual({ + behavior: "allow", + selectedActionId: "implement_resume", + }); + } + }); + it("rejects removed initialize_agent_request inbound payload", () => { const parsed = SessionInboundMessageSchema.safeParse({ type: "initialize_agent_request", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index ba55577c2..4805343b4 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -199,15 +199,24 @@ const AgentSessionConfigSchema = z.object({ }); const AgentPermissionUpdateSchema = z.record(z.unknown()); +const AgentPermissionActionSchema = z.object({ + id: z.string(), + label: z.string(), + behavior: z.enum(["allow", "deny"]), + variant: z.enum(["primary", "secondary", "danger"]).optional(), + intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(), +}); export const AgentPermissionResponseSchema: z.ZodType = z.union([ z.object({ behavior: z.literal("allow"), + selectedActionId: z.string().optional(), updatedInput: z.record(z.unknown()).optional(), updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(), }), z.object({ behavior: z.literal("deny"), + selectedActionId: z.string().optional(), message: z.string().optional(), interrupt: z.boolean().optional(), }), @@ -222,6 +231,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType