feat: normalize plan approval across providers

This commit is contained in:
Mohamed Boudra
2026-04-07 15:43:14 +07:00
parent e06b691d5d
commit 682fc54778
15 changed files with 1137 additions and 84 deletions
@@ -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;
}
@@ -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<void> {
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<AgentSession> {
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<void> {
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<AgentSession> {
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");
@@ -172,6 +172,11 @@ type ManagedAgentBase = {
features?: AgentFeature[];
currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>;
bufferedPermissionResolutions: Map<
string,
Extract<AgentStreamEvent, { type: "permission_resolved" }>
>;
inFlightPermissionResponses: Set<string>;
pendingReplacement: boolean;
timeline: AgentTimelineItem[];
timelineRows: AgentTimelineRow[];
@@ -1437,18 +1442,31 @@ export class AgentManager {
response: AgentPermissionResponse,
): Promise<void> {
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<boolean> {
@@ -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;
@@ -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),
}));
}
@@ -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;
};
@@ -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(),
@@ -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;
}
@@ -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<string, unknown>,
options: Record<string, unknown>,
) => Promise<unknown>;
};
const pendingResolution = internal.handlePermissionRequest(
"ExitPlanMode",
{ plan: "- Implement the approved plan" },
{},
);
const requestEvent = events.find(
(event): event is Extract<AgentStreamEvent, { type: "permission_requested" }> =>
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 {
@@ -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<string, ToolUseCacheEntry>();
private toolUseIndexToId = new Map<number, string>();
@@ -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,
};
@@ -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<AgentStreamEvent, { type: "permission_requested" }> =>
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<AgentStreamEvent, { type: "permission_requested" }> =>
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);
});
});
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isObjectSchemaNode(schema: Record<string, unknown>): 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<string, unknown> = {};
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<string, unknown> {
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<string>();
@@ -2319,6 +2437,7 @@ class CodexAppServerAgentSession implements AgentSession {
private warnedInvalidNotificationPayloads = new Set<string>();
private warnedIncompleteEditToolCallIds = new Set<string>();
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<void> {
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<void> {
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,
@@ -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",
+10
View File
@@ -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<AgentPermissionResponse> = 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<AgentPermissionReque
description: z.string().optional(),
input: z.record(z.unknown()).optional(),
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
actions: z.array(AgentPermissionActionSchema).optional(),
metadata: z.record(z.unknown()).optional(),
});