Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,74 @@ describe("CodexAppServerAgent", () => {
});
});

it("nests subagent updates and ignores child turn completion", async () => {
const stub = makeStubRpc({
initialize: {},
"thread/start": { thread: { id: "thr_1" } },
"turn/start": { turn: { id: "turn_1", status: "inProgress" } },
});
const { client, sessionUpdates } = makeFakeClient();
const agent = new CodexAppServerAgent(client, {
processOptions: { binaryPath: "/bundle/codex" },
model: "gpt-5.5",
rpcFactory: stub.factory,
});

await agent.initialize(init);
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
let promptSettled = false;
const promptDone = agent
.prompt({
sessionId: "thr_1",
prompt: [{ type: "text", text: "review this" }],
} as unknown as PromptRequest)
.then((result) => {
promptSettled = true;
return result;
});

stub.emit("item/started", {
threadId: "thr_1",
turnId: "turn_1",
item: {
type: "collabAgentToolCall",
id: "spawn-1",
tool: "spawnAgent",
status: "inProgress",
senderThreadId: "thr_1",
receiverThreadIds: ["child-1"],
prompt: "Review auth",
},
});
stub.emit("item/agentMessage/delta", {
threadId: "child-1",
turnId: "child-turn",
itemId: "child-message",
delta: "I found an issue.",
});
stub.emit("turn/completed", {
threadId: "child-1",
turn: { id: "child-turn", status: "completed" },
});

await Promise.resolve();
expect(promptSettled).toBe(false);
expect(sessionUpdates).toContainEqual({
sessionId: "thr_1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "I found an issue." },
_meta: { posthog: { parentToolCallId: "spawn-1" } },
},
});

stub.emit("turn/completed", {
threadId: "thr_1",
turn: { id: "turn_1", status: "completed" },
});
await expect(promptDone).resolves.toMatchObject({ stopReason: "end_turn" });
});

it("includes buffered command output when completion omits aggregatedOutput", async () => {
const stub = makeStubRpc({
initialize: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@ import type {
RequestPermissionResponse,
ResumeSessionRequest,
ResumeSessionResponse,
SessionNotification,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
StopReason,
} from "@agentclientprotocol/sdk";
import { mcpToolKey, posthogToolMeta } from "@posthog/shared";
import {
mcpToolKey,
parentToolCallMeta,
posthogToolMeta,
} from "@posthog/shared";
import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions";
import { DEFAULT_CODEX_MODEL } from "../../gateway-models";
import type { ProcessSpawnedCallback } from "../../types";
Expand Down Expand Up @@ -143,6 +148,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
/** Deployment environment; on "cloud" a non-danger sandbox would panic, so we skip the override. */
private environment?: "local" | "cloud";
private readonly commandOutputs = new Map<string, string>();
private readonly subagentParentToolCalls = new Map<string, string>();
/** Extra writable roots for this session, folded into workspaceWrite sandbox turns. */
private additionalDirectories?: string[];
/** The session workspace stays writable when extra roots are applied per turn. */
Expand Down Expand Up @@ -361,6 +367,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
additionalDirectories?: string[];
},
): Promise<{ threadId: string; thread: AppServerThread | undefined }> {
this.subagentParentToolCalls.clear();
this.jsonSchema = params.meta?.jsonSchema ?? undefined;
this.taskRunId = params.meta?.taskRunId;
this.environment = params.meta?.environment;
Expand Down Expand Up @@ -935,32 +942,49 @@ export class CodexAppServerAgent extends BaseAcpAgent {

private handleNotification(method: string, params: unknown): void {
const mappedParams = this.withBufferedCommandOutput(method, params);
if (this.sessionId && !this.session.cancelled) {
this.trackSubagentThreads(mappedParams);
const notificationThreadId = readNotificationThreadId(mappedParams);
const isMainThread =
!notificationThreadId || notificationThreadId === this.threadId;
const parentToolCallId = notificationThreadId
? this.subagentParentToolCalls.get(notificationThreadId)
: undefined;

if (
this.sessionId &&
!this.session.cancelled &&
(isMainThread ||
(parentToolCallId && shouldSurfaceSubagentNotification(method)))
) {
const notification = mapAppServerNotification(
this.sessionId,
method,
mappedParams,
);
if (notification) {
const routedNotification = parentToolCallId
? withParentToolCallId(notification, parentToolCallId)
: notification;
void this.client
.sessionUpdate(notification)
.sessionUpdate(routedNotification)
.catch((err) => this.logger.warn("sessionUpdate failed", err));
this.appendNotification(this.sessionId, notification);
this.appendNotification(this.sessionId, routedNotification);
}
}

if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) {
// Capture the active turn id (steer precondition / interrupt target).
this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id);
}

if (method === APP_SERVER_NOTIFICATIONS.ITEM_STARTED) {
this.mcp.capture(params);
}
if (method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED) {
this.mcp.release(params);
}

if (!isMainThread) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Child Token Usage Is Dropped

When Codex emits thread/tokenUsage/updated for a spawned child thread, this early return skips the usage ingestion and usage extension notification path below. A turn that delegates work to a subagent can then underreport token usage and cost because only the parent thread's usage reaches the tracker.


if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) {
this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id);
}

// codex auto-compaction surfaces as a contextCompaction item: item/started → in progress,
// item/completed → boundary (codex emits no separate thread/compacted; that's a guarded
// fallback). compactionActive dedupes to one boundary per compaction.
Expand Down Expand Up @@ -1032,6 +1056,20 @@ export class CodexAppServerAgent extends BaseAcpAgent {
}
}

private trackSubagentThreads(params: unknown): void {
const item = (params as { item?: AppServerItem } | undefined)?.item;
if (
item?.type !== "collabAgentToolCall" ||
item.tool !== "spawnAgent" ||
!item.id
) {
return;
}
for (const receiverThreadId of item.receiverThreadIds ?? []) {
this.subagentParentToolCalls.set(receiverThreadId, item.id);
}
}

private withBufferedCommandOutput(method: string, params: unknown): unknown {
if (!params || typeof params !== "object") {
return params;
Expand Down Expand Up @@ -1434,6 +1472,53 @@ function mapTurnStopReason(status: string | undefined): StopReason {
return "end_turn";
}

function readNotificationThreadId(params: unknown): string | undefined {
if (!params || typeof params !== "object") return undefined;
const threadId = (params as { threadId?: unknown }).threadId;
return typeof threadId === "string" ? threadId : undefined;
}

function shouldSurfaceSubagentNotification(method: string): boolean {
const surfacedMethods: string[] = [
APP_SERVER_NOTIFICATIONS.AGENT_MESSAGE_DELTA,
APP_SERVER_NOTIFICATIONS.REASONING_TEXT_DELTA,
APP_SERVER_NOTIFICATIONS.REASONING_SUMMARY_TEXT_DELTA,
APP_SERVER_NOTIFICATIONS.PLAN_DELTA,
APP_SERVER_NOTIFICATIONS.ITEM_STARTED,
APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED,
APP_SERVER_NOTIFICATIONS.COMMAND_OUTPUT_DELTA,
APP_SERVER_NOTIFICATIONS.TERMINAL_INTERACTION,
APP_SERVER_NOTIFICATIONS.FILE_CHANGE_PATCH_UPDATED,
];
return surfacedMethods.includes(method);
}

function withParentToolCallId(
notification: SessionNotification,
parentToolCallId: string,
): SessionNotification {
const update = notification.update as SessionNotification["update"] & {
_meta?: Record<string, unknown>;
};
const existingPosthog =
update._meta?.posthog && typeof update._meta.posthog === "object"
? (update._meta.posthog as Record<string, unknown>)
: {};
return {
...notification,
update: {
...update,
_meta: {
...update._meta,
posthog: {
...existingPosthog,
...parentToolCallMeta(parentToolCallId).posthog,
},
},
},
} as SessionNotification;
}

/** The codex thread config override map: folds in MCP servers + makes extra workspace roots writable. Undefined when empty. */
function buildThreadConfig(
mcpServers: ReturnType<typeof toCodexMcpServers>,
Expand Down
68 changes: 68 additions & 0 deletions packages/agent/src/adapters/codex-app-server/mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,74 @@ describe("mapAppServerNotification", () => {
});
});

it("maps a spawned Codex agent to an explicit subagent tool call", () => {
const result = mapAppServerNotification(
"s-1",
APP_SERVER_NOTIFICATIONS.ITEM_STARTED,
{
item: {
type: "collabAgentToolCall",
id: "spawn-1",
tool: "spawnAgent",
status: "inProgress",
senderThreadId: "main-thread",
receiverThreadIds: ["child-thread"],
prompt: "Review the authentication changes\nFocus on security.",
model: "gpt-5.5",
reasoningEffort: "high",
},
},
);

expect(result).toEqual({
sessionId: "s-1",
update: {
sessionUpdate: "tool_call",
toolCallId: "spawn-1",
title: "Review the authentication changes",
kind: "other",
status: "in_progress",
rawInput: {
prompt: "Review the authentication changes\nFocus on security.",
receiverThreadIds: ["child-thread"],
model: "gpt-5.5",
reasoningEffort: "high",
},
_meta: { posthog: { toolName: "spawn_agent" } },
},
});
});

it("keeps a completed spawn tool call active while its subagent is running", () => {
const result = mapAppServerNotification(
"s-1",
APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED,
{
item: {
type: "collabAgentToolCall",
id: "spawn-1",
tool: "spawnAgent",
status: "completed",
senderThreadId: "main-thread",
receiverThreadIds: ["child-thread"],
prompt: "Review the authentication changes",
agentsStates: {
"child-thread": { status: "running", message: null },
},
},
},
);

expect(result).toEqual({
sessionId: "s-1",
update: {
sessionUpdate: "tool_call_update",
toolCallId: "spawn-1",
status: "in_progress",
},
});
});

it("drops agent message items (their deltas already streamed)", () => {
expect(
mapAppServerNotification("s-1", APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, {
Expand Down
Loading
Loading