Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

333 changes: 298 additions & 35 deletions packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts

Large diffs are not rendered by default.

36 changes: 32 additions & 4 deletions packages/agent/src/adapters/codex-app-server/mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,22 @@ describe("mapAppServerNotification", () => {
});
});

it("streams a plan delta as an ACP agent_message_chunk", () => {
const result = mapAppServerNotification(
"s-1",
APP_SERVER_NOTIFICATIONS.PLAN_DELTA,
{ itemId: "p1", delta: "## Plan\n" },
);

expect(result).toEqual({
sessionId: "s-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "## Plan\n" },
},
});
});

it("returns null when the delta is missing or empty", () => {
expect(
mapAppServerNotification(
Expand Down Expand Up @@ -525,6 +541,21 @@ describe("mapHistoryItem", () => {
]);
});

it("replays a persisted plan item as an agent_message_chunk", () => {
expect(
mapHistoryItem("s-1", { type: "plan", id: "p1", text: "# The plan" }),
).toEqual([
{
sessionId: "s-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "# The plan" },
},
},
]);
expect(mapHistoryItem("s-1", { type: "plan", id: "p1" })).toEqual([]);
});

it("replays an agentMessage as an agent_message_chunk", () => {
expect(
mapHistoryItem("s-1", { type: "agentMessage", id: "a1", text: "done" }),
Expand Down Expand Up @@ -583,11 +614,8 @@ describe("mapHistoryItem", () => {
});
});

it("does not replay ephemeral reasoning/plan items", () => {
it("does not replay ephemeral reasoning items", () => {
expect(mapHistoryItem("s-1", { type: "reasoning", id: "r1" })).toEqual([]);
expect(
mapHistoryItem("s-1", { type: "plan", id: "p1", text: "the plan" }),
).toEqual([]);
});
});

Expand Down
28 changes: 26 additions & 2 deletions packages/agent/src/adapters/codex-app-server/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ export function mapAppServerNotification(
},
};
}
// Plan-mode proposal streaming as agent prose (codex strips it from agentMessage deltas).
case APP_SERVER_NOTIFICATIONS.PLAN_DELTA: {
const delta = readStringField(params, "delta");
if (!delta) return null;
return {
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: delta },
},
};
}
case APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED: {
// Context indicator: renderer reads `used`/`size`; detailed breakdown comes via `_posthog/usage_update`.
const usage = readTokenUsage(params);
Expand Down Expand Up @@ -232,7 +244,7 @@ function dynamicToolText(items: unknown): string | null {
/**
* Re-renders a persisted `ThreadItem` as the ACP updates a live stream would have produced,
* so a reattaching host shows the full transcript. Tool items collapse to one completed
* `tool_call`; ephemeral items (reasoning, plan) are not replayed.
* `tool_call`; reasoning is not replayed.
*/
export function mapHistoryItem(
sessionId: string,
Expand All @@ -254,8 +266,20 @@ export function mapHistoryItem(
]
: [];
case "reasoning":
case "plan":
return [];
// Replay the proposed plan as agent prose so a reattached host still shows it.
case "plan":
return item.text
? [
{
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: item.text },
},
},
]
: [];
default: {
const tool = describeTool(item);
if (!tool || !item.id) return [];
Expand Down
3 changes: 3 additions & 0 deletions packages/agent/src/adapters/codex-app-server/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export const APP_SERVER_NOTIFICATIONS = {
REASONING_TEXT_DELTA: "item/reasoning/textDelta",
// Default reasoning stream for gpt-5 models; raw textDelta is off by default, so without this the host sees no reasoning.
REASONING_SUMMARY_TEXT_DELTA: "item/reasoning/summaryTextDelta",
// Plan-mode <proposed_plan> stream. codex strips the plan from agentMessage deltas,
// so without this the host sees nothing while the plan is written.
PLAN_DELTA: "item/plan/delta",
TURN_PLAN_UPDATED: "turn/plan/updated",
TURN_COMPLETED: "turn/completed",
// Fatal turn error; `willRetry:false` means it won't recover on its own.
Expand Down
2 changes: 1 addition & 1 deletion packages/api-client/src/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4277,7 +4277,7 @@ export namespace Schemas {
};
export type CodeInviteRedeemRequest = { code: string };
export type CodexRuntimeAdapterEnum = "codex";
export type CodexTaskRunCreateSchemaInitialPermissionModeEnum = "auto" | "read-only" | "full-access";
export type CodexTaskRunCreateSchemaInitialPermissionModeEnum = "plan" | "auto" | "read-only" | "full-access";
export type CodexTaskRunCreateSchema = {
mode?: (TaskExecutionModeEnum & unknown) | undefined;
branch?: (string | null) | undefined;
Expand Down
4 changes: 2 additions & 2 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ describe("PostHogAPIClient", () => {
);
});

it("maps plan to read-only for cloud Codex runs", async () => {
it("preserves plan for cloud Codex runs", async () => {
const client = new PostHogAPIClient(
"http://localhost:8000",
async () => "token",
Expand Down Expand Up @@ -106,7 +106,7 @@ describe("PostHogAPIClient", () => {
"/api/projects/{project_id}/tasks/{id}/run/",
expect.objectContaining({
body: expect.objectContaining({
initial_permission_mode: "read-only",
initial_permission_mode: "plan",
}),
}),
);
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/execution-modes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe("resolveCloudInitialPermissionMode", () => {
["codex", "auto", "auto"],
["codex", "read-only", "read-only"],
["codex", "full-access", "full-access"],
["codex", "plan", "read-only"],
["codex", "plan", "plan"],
["codex", "default", "auto"],
["codex", "acceptEdits", "auto"],
["codex", "bypassPermissions", "full-access"],
Expand Down
8 changes: 4 additions & 4 deletions packages/shared/src/execution-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const CLAUDE_CLOUD_PERMISSION_MODES = [
] as const;

const CODEX_CLOUD_PERMISSION_MODES = [
"plan",
"auto",
"read-only",
"full-access",
Expand All @@ -65,19 +66,18 @@ function isCodexCloudPermissionMode(
return (CODEX_CLOUD_PERMISSION_MODES as readonly string[]).includes(mode);
}

// The cloud API's per-adapter mode enums are narrower than the local presets (no "plan" for codex); degrade to the nearest permission ceiling.
// Translate presets that only exist on the other adapter to the nearest permission ceiling.
const CODEX_CLOUD_MODE_FALLBACKS: Record<
Exclude<ClaudeCloudPermissionMode, "auto">,
Exclude<ClaudeCloudPermissionMode, "auto" | "plan">,
CodexCloudPermissionMode
> = {
default: "auto",
acceptEdits: "auto",
plan: "read-only",
bypassPermissions: "full-access",
};

const CLAUDE_CLOUD_MODE_FALLBACKS: Record<
Exclude<CodexCloudPermissionMode, "auto">,
Exclude<CodexCloudPermissionMode, "auto" | "plan">,
ClaudeCloudPermissionMode
> = {
"read-only": "plan",
Expand Down
Loading