feat: normalize plan approval across providers
This commit is contained in:
@@ -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 <previous mode>`.
|
||||||
|
- 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`
|
||||||
@@ -38,7 +38,10 @@ import {
|
|||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { 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 type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
@@ -694,6 +697,29 @@ function PermissionRequestCard({
|
|||||||
const isPlanRequest = request.kind === "plan";
|
const isPlanRequest = request.kind === "plan";
|
||||||
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
const title = isPlanRequest ? "Plan" : (request.title ?? request.name ?? "Permission Required");
|
||||||
const description = request.description ?? "";
|
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(() => {
|
const planMarkdown = useMemo(() => {
|
||||||
if (!request) {
|
if (!request) {
|
||||||
@@ -734,11 +760,11 @@ function PermissionRequestCard({
|
|||||||
isPending: isResponding,
|
isPending: isResponding,
|
||||||
} = permissionMutation;
|
} = permissionMutation;
|
||||||
|
|
||||||
const [respondingAction, setRespondingAction] = useState<"accept" | "deny" | null>(null);
|
const [respondingActionId, setRespondingActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetPermissionMutation();
|
resetPermissionMutation();
|
||||||
setRespondingAction(null);
|
setRespondingActionId(null);
|
||||||
}, [permission.request.id, resetPermissionMutation]);
|
}, [permission.request.id, resetPermissionMutation]);
|
||||||
const handleResponse = useCallback(
|
const handleResponse = useCallback(
|
||||||
(response: AgentPermissionResponse) => {
|
(response: AgentPermissionResponse) => {
|
||||||
@@ -752,6 +778,24 @@ function PermissionRequestCard({
|
|||||||
},
|
},
|
||||||
[permission.agentId, permission.request.id, respondToPermission],
|
[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") {
|
if (request.kind === "question") {
|
||||||
return (
|
return (
|
||||||
@@ -778,64 +822,48 @@ function PermissionRequestCard({
|
|||||||
!isMobile && permissionStyles.optionsContainerDesktop,
|
!isMobile && permissionStyles.optionsContainerDesktop,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Pressable
|
{resolvedActions.map((action) => {
|
||||||
testID="permission-request-deny"
|
const isDanger = action.variant === "danger" || action.behavior === "deny";
|
||||||
style={({ pressed, hovered = false }) => [
|
const isPrimary = action.variant === "primary";
|
||||||
permissionStyles.optionButton,
|
const isRespondingAction = respondingActionId === action.id;
|
||||||
{
|
const textColor = isPrimary ? theme.colors.foreground : theme.colors.foregroundMuted;
|
||||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
const iconColor = textColor;
|
||||||
borderColor: theme.colors.borderAccent,
|
const Icon = action.behavior === "allow" ? Check : X;
|
||||||
},
|
const testID =
|
||||||
pressed ? permissionStyles.optionButtonPressed : null,
|
action.behavior === "deny"
|
||||||
]}
|
? "permission-request-deny"
|
||||||
onPress={() => {
|
: action.id === "accept" || action.id === "implement"
|
||||||
setRespondingAction("deny");
|
? "permission-request-accept"
|
||||||
handleResponse({
|
: `permission-request-action-${action.id}`;
|
||||||
behavior: "deny",
|
|
||||||
message: "Denied by user",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={isResponding}
|
|
||||||
>
|
|
||||||
{respondingAction === "deny" ? (
|
|
||||||
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
||||||
) : (
|
|
||||||
<View style={permissionStyles.optionContent}>
|
|
||||||
<X size={14} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foregroundMuted }]}>
|
|
||||||
Deny
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Pressable
|
return (
|
||||||
testID="permission-request-accept"
|
<Pressable
|
||||||
style={({ pressed, hovered = false }) => [
|
key={action.id}
|
||||||
permissionStyles.optionButton,
|
testID={testID}
|
||||||
{
|
style={({ pressed, hovered = false }) => [
|
||||||
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
permissionStyles.optionButton,
|
||||||
borderColor: theme.colors.borderAccent,
|
{
|
||||||
},
|
backgroundColor: hovered ? theme.colors.surface2 : theme.colors.surface1,
|
||||||
pressed ? permissionStyles.optionButtonPressed : null,
|
borderColor: isDanger ? theme.colors.borderAccent : theme.colors.borderAccent,
|
||||||
]}
|
},
|
||||||
onPress={() => {
|
pressed ? permissionStyles.optionButtonPressed : null,
|
||||||
setRespondingAction("accept");
|
]}
|
||||||
handleResponse({ behavior: "allow" });
|
onPress={() => handleActionPress(action)}
|
||||||
}}
|
disabled={isResponding}
|
||||||
disabled={isResponding}
|
>
|
||||||
>
|
{isRespondingAction ? (
|
||||||
{respondingAction === "accept" ? (
|
<ActivityIndicator size="small" color={textColor} />
|
||||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
) : (
|
||||||
) : (
|
<View style={permissionStyles.optionContent}>
|
||||||
<View style={permissionStyles.optionContent}>
|
<Icon size={14} color={iconColor} />
|
||||||
<Check size={14} color={theme.colors.foreground} />
|
<Text style={[permissionStyles.optionText, { color: textColor }]}>
|
||||||
<Text style={[permissionStyles.optionText, { color: theme.colors.foreground }]}>
|
{action.label}
|
||||||
Accept
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
</Pressable>
|
||||||
</Pressable>
|
);
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -171,6 +171,9 @@ function sanitizePermissionRequest(
|
|||||||
if (sanitized.suggestions === undefined) {
|
if (sanitized.suggestions === undefined) {
|
||||||
delete sanitized.suggestions;
|
delete sanitized.suggestions;
|
||||||
}
|
}
|
||||||
|
if (sanitized.actions === undefined) {
|
||||||
|
delete sanitized.actions;
|
||||||
|
}
|
||||||
if (sanitized.metadata === undefined) {
|
if (sanitized.metadata === undefined) {
|
||||||
delete sanitized.metadata;
|
delete sanitized.metadata;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2890,6 +2890,213 @@ describe("AgentManager", () => {
|
|||||||
expect(updatedAgent?.currentModeId).toBe("acceptEdits");
|
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 () => {
|
test("close during in-flight stream does not clear persistence sessionId", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ type ManagedAgentBase = {
|
|||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
currentModeId: string | null;
|
currentModeId: string | null;
|
||||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||||
|
bufferedPermissionResolutions: Map<
|
||||||
|
string,
|
||||||
|
Extract<AgentStreamEvent, { type: "permission_resolved" }>
|
||||||
|
>;
|
||||||
|
inFlightPermissionResponses: Set<string>;
|
||||||
pendingReplacement: boolean;
|
pendingReplacement: boolean;
|
||||||
timeline: AgentTimelineItem[];
|
timeline: AgentTimelineItem[];
|
||||||
timelineRows: AgentTimelineRow[];
|
timelineRows: AgentTimelineRow[];
|
||||||
@@ -1437,18 +1442,31 @@ export class AgentManager {
|
|||||||
response: AgentPermissionResponse,
|
response: AgentPermissionResponse,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
await agent.session.respondToPermission(requestId, response);
|
agent.inFlightPermissionResponses.add(requestId);
|
||||||
agent.pendingPermissions.delete(requestId);
|
|
||||||
|
|
||||||
// Update currentModeId - the session may have changed mode internally
|
|
||||||
// (e.g., plan approval changes mode from "plan" to "acceptEdits")
|
|
||||||
try {
|
try {
|
||||||
agent.currentModeId = await agent.session.getCurrentMode();
|
await agent.session.respondToPermission(requestId, response);
|
||||||
} catch {
|
agent.pendingPermissions.delete(requestId);
|
||||||
// Ignore errors from getCurrentMode - mode tracking is best effort
|
|
||||||
}
|
|
||||||
|
|
||||||
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> {
|
async cancelAgentRun(agentId: string): Promise<boolean> {
|
||||||
@@ -1799,6 +1817,8 @@ export class AgentManager {
|
|||||||
availableModes: [],
|
availableModes: [],
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
|
bufferedPermissionResolutions: new Map(),
|
||||||
|
inFlightPermissionResponses: new Set(),
|
||||||
pendingReplacement: false,
|
pendingReplacement: false,
|
||||||
activeForegroundTurnId: null,
|
activeForegroundTurnId: null,
|
||||||
foregroundTurnWaiters: new Set(),
|
foregroundTurnWaiters: new Set(),
|
||||||
@@ -2011,6 +2031,7 @@ export class AgentManager {
|
|||||||
agent.pendingPermissions.clear();
|
agent.pendingPermissions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.syncFeaturesFromSession(agent);
|
||||||
await this.refreshRuntimeInfo(agent);
|
await this.refreshRuntimeInfo(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2087,6 +2108,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timelineRow: AgentTimelineRow | null = null;
|
let timelineRow: AgentTimelineRow | null = null;
|
||||||
|
let shouldDispatchEvent = true;
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "thread_started":
|
case "thread_started":
|
||||||
@@ -2259,6 +2281,11 @@ export class AgentManager {
|
|||||||
break;
|
break;
|
||||||
case "permission_resolved":
|
case "permission_resolved":
|
||||||
agent.pendingPermissions.delete(event.requestId);
|
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);
|
this.emitState(agent);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -2270,7 +2297,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip dispatching individual stream events during history replay.
|
// Skip dispatching individual stream events during history replay.
|
||||||
if (!options?.fromHistory) {
|
if (!options?.fromHistory && shouldDispatchEvent) {
|
||||||
this.dispatchStream(
|
this.dispatchStream(
|
||||||
agent.id,
|
agent.id,
|
||||||
event,
|
event,
|
||||||
@@ -2365,9 +2392,7 @@ export class AgentManager {
|
|||||||
// Keep attention as an edge-triggered unread signal, not a level signal.
|
// Keep attention as an edge-triggered unread signal, not a level signal.
|
||||||
this.checkAndSetAttention(agent);
|
this.checkAndSetAttention(agent);
|
||||||
|
|
||||||
if (agent.session?.features) {
|
this.syncFeaturesFromSession(agent);
|
||||||
agent.features = agent.session.features;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dispatch({
|
this.dispatch({
|
||||||
type: "agent_state",
|
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 {
|
private checkAndSetAttention(agent: ManagedAgent): void {
|
||||||
const previousStatus = this.previousStatuses.get(agent.id);
|
const previousStatus = this.previousStatuses.get(agent.id);
|
||||||
const currentStatus = agent.lifecycle;
|
const currentStatus = agent.lifecycle;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export function toStoredAgentRecord(
|
|||||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||||
config: config ?? null,
|
config: config ?? null,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
|
features: agent.features,
|
||||||
persistence,
|
persistence,
|
||||||
requiresAttention: agent.attention.requiresAttention,
|
requiresAttention: agent.attention.requiresAttention,
|
||||||
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
||||||
@@ -166,6 +167,7 @@ function sanitizePendingPermissions(
|
|||||||
...request,
|
...request,
|
||||||
input: sanitizeMetadata(request.input),
|
input: sanitizeMetadata(request.input),
|
||||||
suggestions: sanitizeMetadataArray(request.suggestions),
|
suggestions: sanitizeMetadataArray(request.suggestions),
|
||||||
|
actions: request.actions?.map((action) => ({ ...action })),
|
||||||
metadata: sanitizeMetadata(request.metadata),
|
metadata: sanitizeMetadata(request.metadata),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,6 +330,14 @@ export type AgentPermissionRequestKind = "tool" | "plan" | "question" | "mode" |
|
|||||||
|
|
||||||
export type AgentPermissionUpdate = AgentMetadata;
|
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 = {
|
export type AgentPermissionRequest = {
|
||||||
id: string;
|
id: string;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -340,17 +348,20 @@ export type AgentPermissionRequest = {
|
|||||||
input?: AgentMetadata;
|
input?: AgentMetadata;
|
||||||
detail?: ToolCallDetail;
|
detail?: ToolCallDetail;
|
||||||
suggestions?: AgentPermissionUpdate[];
|
suggestions?: AgentPermissionUpdate[];
|
||||||
|
actions?: AgentPermissionAction[];
|
||||||
metadata?: AgentMetadata;
|
metadata?: AgentMetadata;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentPermissionResponse =
|
export type AgentPermissionResponse =
|
||||||
| {
|
| {
|
||||||
behavior: "allow";
|
behavior: "allow";
|
||||||
|
selectedActionId?: string;
|
||||||
updatedInput?: AgentMetadata;
|
updatedInput?: AgentMetadata;
|
||||||
updatedPermissions?: AgentPermissionUpdate[];
|
updatedPermissions?: AgentPermissionUpdate[];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
behavior: "deny";
|
behavior: "deny";
|
||||||
|
selectedActionId?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
interrupt?: boolean;
|
interrupt?: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { AgentStatusSchema } from "../messages.js";
|
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import type { AgentSessionConfig } from "./agent-sdk-types.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(),
|
extra: z.record(z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
features: z.array(AgentFeatureSchema).optional(),
|
||||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||||
requiresAttention: z.boolean().optional(),
|
requiresAttention: z.boolean().optional(),
|
||||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ function sanitizePermissionRequest(
|
|||||||
if (sanitized.suggestions === undefined) {
|
if (sanitized.suggestions === undefined) {
|
||||||
delete sanitized.suggestions;
|
delete sanitized.suggestions;
|
||||||
}
|
}
|
||||||
|
if (sanitized.actions === undefined) {
|
||||||
|
delete sanitized.actions;
|
||||||
|
}
|
||||||
if (sanitized.metadata === undefined) {
|
if (sanitized.metadata === undefined) {
|
||||||
delete sanitized.metadata;
|
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 () => {
|
test("reuses one autonomous run for unbound stream_event bursts with no foreground run", async () => {
|
||||||
const session = await createSession();
|
const session = await createSession();
|
||||||
const internal = session as unknown as {
|
const internal = session as unknown as {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
} from "./diagnostic-utils.js";
|
} from "./diagnostic-utils.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AgentPermissionAction,
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
@@ -670,6 +671,43 @@ function resolvePermissionKind(
|
|||||||
return "tool";
|
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 = {
|
type TimelineFragment = {
|
||||||
kind: "assistant" | "reasoning";
|
kind: "assistant" | "reasoning";
|
||||||
text: string;
|
text: string;
|
||||||
@@ -1212,6 +1250,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private claudeSessionId: string | null;
|
private claudeSessionId: string | null;
|
||||||
private persistence: AgentPersistenceHandle | null;
|
private persistence: AgentPersistenceHandle | null;
|
||||||
private currentMode: PermissionMode;
|
private currentMode: PermissionMode;
|
||||||
|
private planResumeMode: PermissionMode | null = null;
|
||||||
private availableModes: AgentMode[] = DEFAULT_MODES;
|
private availableModes: AgentMode[] = DEFAULT_MODES;
|
||||||
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
private toolUseCache = new Map<string, ToolUseCacheEntry>();
|
||||||
private toolUseIndexToId = new Map<number, string>();
|
private toolUseIndexToId = new Map<number, string>();
|
||||||
@@ -1273,6 +1312,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
|
this.currentMode = isPermissionMode(config.modeId) ? config.modeId : "default";
|
||||||
|
if (this.currentMode !== "plan") {
|
||||||
|
this.planResumeMode = this.currentMode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get id(): string | null {
|
get id(): string | null {
|
||||||
@@ -1515,8 +1557,16 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
||||||
|
const previousMode = this.currentMode;
|
||||||
const query = await this.ensureQuery();
|
const query = await this.ensureQuery();
|
||||||
await query.setPermissionMode(normalized);
|
await query.setPermissionMode(normalized);
|
||||||
|
if (normalized === "plan") {
|
||||||
|
if (previousMode !== "plan") {
|
||||||
|
this.planResumeMode = previousMode;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.planResumeMode = normalized;
|
||||||
|
}
|
||||||
this.currentMode = normalized;
|
this.currentMode = normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1563,13 +1613,22 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
if (response.behavior === "allow") {
|
if (response.behavior === "allow") {
|
||||||
if (pending.request.kind === "plan") {
|
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(
|
this.pushToolCall(
|
||||||
mapClaudeCompletedToolCall({
|
mapClaudeCompletedToolCall({
|
||||||
name: "plan_approval",
|
name: "plan_approval",
|
||||||
callId: pending.request.id,
|
callId: pending.request.id,
|
||||||
input: pending.request.input ?? null,
|
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.availableModes = DEFAULT_MODES;
|
||||||
this.currentMode = message.permissionMode;
|
this.currentMode = message.permissionMode;
|
||||||
|
if (this.currentMode !== "plan") {
|
||||||
|
this.planResumeMode = this.currentMode;
|
||||||
|
}
|
||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
if (message.model) {
|
if (message.model) {
|
||||||
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
const normalizedRuntimeModel = normalizeClaudeRuntimeModelId(message.model);
|
||||||
@@ -2907,6 +2969,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
input,
|
input,
|
||||||
detail: toolDetail,
|
detail: toolDetail,
|
||||||
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
suggestions: options.suggestions?.map((suggestion) => ({ ...suggestion })),
|
||||||
|
actions:
|
||||||
|
kind === "plan" ? buildClaudePlanPermissionActions(this.planResumeMode) : undefined,
|
||||||
metadata: Object.keys(metadata).length ? metadata : 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 { existsSync, rmSync } from "node:fs";
|
||||||
|
|
||||||
import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
|
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 () => {
|
test("maps image prompt blocks to Codex localImage input", async () => {
|
||||||
const input = await codexAppServerTurnInputFromPrompt(
|
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 {
|
import type {
|
||||||
|
AgentPermissionAction,
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentFeature,
|
AgentFeature,
|
||||||
@@ -64,6 +65,8 @@ const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
|||||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||||
const CODEX_PROVIDER = "codex" as const;
|
const CODEX_PROVIDER = "codex" as const;
|
||||||
const CODEX_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
|
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 = {
|
const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
@@ -143,6 +146,73 @@ function normalizeCodexModelLabel(displayName: string): string {
|
|||||||
return displayName.replace(/\bgpt\b/gi, "GPT");
|
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 = {
|
type CodexConfiguredDefaults = {
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: 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 = {
|
type CodexQuestionOption = {
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -2299,8 +2416,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
kind: "command" | "file" | "question";
|
kind: "command" | "file" | "question" | "plan";
|
||||||
questions?: CodexQuestionPrompt[];
|
questions?: CodexQuestionPrompt[];
|
||||||
|
planText?: string;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
private resolvedPermissionRequests = new Set<string>();
|
private resolvedPermissionRequests = new Set<string>();
|
||||||
@@ -2319,6 +2437,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private warnedInvalidNotificationPayloads = new Set<string>();
|
private warnedInvalidNotificationPayloads = new Set<string>();
|
||||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||||
private latestUsage: AgentUsage | undefined;
|
private latestUsage: AgentUsage | undefined;
|
||||||
|
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private collaborationModes: Array<{
|
private collaborationModes: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2502,6 +2621,79 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.resolvedCollaborationMode = this.resolveCollaborationMode();
|
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 {
|
private registerRequestHandlers(): void {
|
||||||
if (!this.client) return;
|
if (!this.client) return;
|
||||||
|
|
||||||
@@ -2804,7 +2996,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
params.cwd = this.config.cwd;
|
params.cwd = this.config.cwd;
|
||||||
}
|
}
|
||||||
if (options?.outputSchema) {
|
if (options?.outputSchema) {
|
||||||
params.outputSchema = options.outputSchema;
|
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
|
||||||
}
|
}
|
||||||
if (this.config.systemPrompt?.trim()) {
|
if (this.config.systemPrompt?.trim()) {
|
||||||
params.developerInstructions = 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> {
|
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||||
if (featureId === "fast_mode") {
|
if (featureId === "fast_mode") {
|
||||||
this.serviceTier = value ? "fast" : null;
|
this.applyFeatureValue("fast_mode", Boolean(value));
|
||||||
this.cachedRuntimeInfo = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (featureId === "plan_mode") {
|
if (featureId === "plan_mode") {
|
||||||
this.planModeEnabled = Boolean(value);
|
this.applyFeatureValue("plan_mode", Boolean(value));
|
||||||
this.refreshResolvedCollaborationMode();
|
|
||||||
this.cachedRuntimeInfo = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new Error(`Unknown Codex feature: ${featureId}`);
|
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}'`);
|
throw new Error(`No pending Codex app-server permission request with id '${requestId}'`);
|
||||||
}
|
}
|
||||||
const pendingRequest = this.pendingPermissions.get(requestId) ?? null;
|
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.pendingPermissionHandlers.delete(requestId);
|
||||||
this.pendingPermissions.delete(requestId);
|
this.pendingPermissions.delete(requestId);
|
||||||
this.resolvedPermissionRequests.add(requestId);
|
this.resolvedPermissionRequests.add(requestId);
|
||||||
@@ -3223,6 +3432,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
if (parsed.kind === "turn_started") {
|
if (parsed.kind === "turn_started") {
|
||||||
this.currentTurnId = parsed.turnId;
|
this.currentTurnId = parsed.turnId;
|
||||||
|
this.latestPlanResult = null;
|
||||||
this.emittedItemStartedIds.clear();
|
this.emittedItemStartedIds.clear();
|
||||||
this.emittedItemCompletedIds.clear();
|
this.emittedItemCompletedIds.clear();
|
||||||
this.emittedExecCommandStartedCallIds.clear();
|
this.emittedExecCommandStartedCallIds.clear();
|
||||||
@@ -3244,6 +3454,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
} else if (parsed.status === "interrupted") {
|
} else if (parsed.status === "interrupted") {
|
||||||
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
|
||||||
} else {
|
} else {
|
||||||
|
if (this.planModeEnabled && this.latestPlanResult?.text) {
|
||||||
|
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
|
||||||
|
}
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "turn_completed",
|
type: "turn_completed",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
@@ -3251,6 +3464,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
|
this.latestPlanResult = null;
|
||||||
this.emittedItemStartedIds.clear();
|
this.emittedItemStartedIds.clear();
|
||||||
this.emittedItemCompletedIds.clear();
|
this.emittedItemCompletedIds.clear();
|
||||||
this.emittedExecCommandStartedCallIds.clear();
|
this.emittedExecCommandStartedCallIds.clear();
|
||||||
@@ -3272,6 +3486,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (timelineItem) {
|
if (timelineItem) {
|
||||||
|
this.rememberPlanResult(timelineItem);
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: CODEX_PROVIDER,
|
provider: CODEX_PROVIDER,
|
||||||
@@ -3465,6 +3680,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (timelineItem.type === "tool_call") {
|
if (timelineItem.type === "tool_call") {
|
||||||
|
if (timelineItem.detail.type === "plan") {
|
||||||
|
this.rememberPlanResult(timelineItem);
|
||||||
|
}
|
||||||
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
|
||||||
}
|
}
|
||||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||||
@@ -4038,6 +4256,7 @@ export const __codexAppServerInternals = {
|
|||||||
mapCodexPatchNotificationToToolCall,
|
mapCodexPatchNotificationToToolCall,
|
||||||
planStepsToMarkdown,
|
planStepsToMarkdown,
|
||||||
mapCodexPlanToToolCall,
|
mapCodexPlanToToolCall,
|
||||||
|
normalizeCodexOutputSchema,
|
||||||
normalizeCodexQuestionPrompts,
|
normalizeCodexQuestionPrompts,
|
||||||
toAgentUsage,
|
toAgentUsage,
|
||||||
threadItemToTimeline,
|
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", () => {
|
it("rejects removed initialize_agent_request inbound payload", () => {
|
||||||
const parsed = SessionInboundMessageSchema.safeParse({
|
const parsed = SessionInboundMessageSchema.safeParse({
|
||||||
type: "initialize_agent_request",
|
type: "initialize_agent_request",
|
||||||
|
|||||||
@@ -199,15 +199,24 @@ const AgentSessionConfigSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const AgentPermissionUpdateSchema = z.record(z.unknown());
|
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([
|
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
behavior: z.literal("allow"),
|
behavior: z.literal("allow"),
|
||||||
|
selectedActionId: z.string().optional(),
|
||||||
updatedInput: z.record(z.unknown()).optional(),
|
updatedInput: z.record(z.unknown()).optional(),
|
||||||
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
|
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
behavior: z.literal("deny"),
|
behavior: z.literal("deny"),
|
||||||
|
selectedActionId: z.string().optional(),
|
||||||
message: z.string().optional(),
|
message: z.string().optional(),
|
||||||
interrupt: z.boolean().optional(),
|
interrupt: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
@@ -222,6 +231,7 @@ export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionReque
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
input: z.record(z.unknown()).optional(),
|
input: z.record(z.unknown()).optional(),
|
||||||
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
suggestions: z.array(AgentPermissionUpdateSchema).optional(),
|
||||||
|
actions: z.array(AgentPermissionActionSchema).optional(),
|
||||||
metadata: z.record(z.unknown()).optional(),
|
metadata: z.record(z.unknown()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user