Skip to content

Commit f2fe4db

Browse files
Fix Codex subagent thread isolation
Keep child-thread output, usage, compaction, and completion notifications from mutating the parent ACP session. Generated-By: PostHog Code Task-Id: 971044c5-e622-4f07-b1b5-dca9b06d040c
1 parent e59bf31 commit f2fe4db

2 files changed

Lines changed: 109 additions & 15 deletions

File tree

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,86 @@ describe("CodexAppServerAgent", () => {
134134
});
135135
});
136136

137+
it("isolates subagent output, usage, compaction, and completion", async () => {
138+
const stub = makeStubRpc({
139+
initialize: {},
140+
"thread/start": { thread: { id: "thr_1" } },
141+
"turn/start": { turn: { id: "turn_1", status: "inProgress" } },
142+
});
143+
const { client, sessionUpdates, extNotifications } = makeFakeClient();
144+
const agent = new CodexAppServerAgent(client, {
145+
processOptions: { binaryPath: "/bundle/codex" },
146+
model: "gpt-5.5",
147+
rpcFactory: stub.factory,
148+
});
149+
150+
await agent.initialize(init);
151+
await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest);
152+
const promptDone = agent.prompt({
153+
sessionId: "thr_1",
154+
prompt: [{ type: "text", text: "delegate this" }],
155+
} as unknown as PromptRequest);
156+
const sessionUpdateCount = sessionUpdates.length;
157+
const extNotificationCount = extNotifications.length;
158+
159+
stub.emit("item/agentMessage/delta", {
160+
threadId: "subagent_1",
161+
turnId: "subagent_turn_1",
162+
itemId: "subagent_message_1",
163+
delta: "subagent prose",
164+
});
165+
stub.emit("thread/tokenUsage/updated", {
166+
threadId: "subagent_1",
167+
tokenUsage: {
168+
total: { totalTokens: 9000 },
169+
modelContextWindow: 10000,
170+
},
171+
});
172+
stub.emit("item/started", {
173+
threadId: "subagent_1",
174+
turnId: "subagent_turn_1",
175+
item: { type: "contextCompaction", id: "subagent_compaction_1" },
176+
});
177+
stub.emit("item/completed", {
178+
threadId: "subagent_1",
179+
turnId: "subagent_turn_1",
180+
item: { type: "contextCompaction", id: "subagent_compaction_1" },
181+
});
182+
stub.emit("turn/completed", {
183+
threadId: "subagent_1",
184+
turn: { id: "subagent_turn_1", status: "completed" },
185+
});
186+
187+
let promptSettled = false;
188+
void promptDone.then(() => {
189+
promptSettled = true;
190+
});
191+
await Promise.resolve();
192+
expect({
193+
extNotifications: extNotifications.length,
194+
promptSettled,
195+
sessionUpdates: sessionUpdates.length,
196+
}).toEqual({
197+
extNotifications: extNotificationCount,
198+
promptSettled: false,
199+
sessionUpdates: sessionUpdateCount,
200+
});
201+
202+
stub.emit("item/agentMessage/delta", {
203+
threadId: "thr_1",
204+
turnId: "turn_1",
205+
itemId: "message_1",
206+
delta: "parent response",
207+
});
208+
stub.emit("turn/completed", {
209+
threadId: "thr_1",
210+
turn: { id: "turn_1", status: "completed" },
211+
});
212+
213+
await expect(promptDone).resolves.toMatchObject({ stopReason: "end_turn" });
214+
expect(JSON.stringify(sessionUpdates)).toContain("parent response");
215+
});
216+
137217
it("includes buffered command output when completion omits aggregatedOutput", async () => {
138218
const stub = makeStubRpc({
139219
initialize: {},

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -938,32 +938,40 @@ export class CodexAppServerAgent extends BaseAcpAgent {
938938

939939
private handleNotification(method: string, params: unknown): void {
940940
const mappedParams = this.withBufferedCommandOutput(method, params);
941+
const notificationThreadId = readNotificationThreadId(mappedParams);
942+
const isMainThread =
943+
!notificationThreadId || notificationThreadId === this.threadId;
944+
941945
if (this.sessionId && !this.session.cancelled) {
942-
const notification = mapAppServerNotification(
943-
this.sessionId,
944-
method,
945-
mappedParams,
946-
);
947-
if (notification) {
948-
void this.client
949-
.sessionUpdate(notification)
950-
.catch((err) => this.logger.warn("sessionUpdate failed", err));
951-
this.appendNotification(this.sessionId, notification);
946+
if (isMainThread) {
947+
const notification = mapAppServerNotification(
948+
this.sessionId,
949+
method,
950+
mappedParams,
951+
);
952+
if (notification) {
953+
void this.client
954+
.sessionUpdate(notification)
955+
.catch((err) => this.logger.warn("sessionUpdate failed", err));
956+
this.appendNotification(this.sessionId, notification);
957+
}
952958
}
953959
}
954960

955-
if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) {
956-
// Capture the active turn id (steer precondition / interrupt target).
957-
this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id);
958-
}
959-
960961
if (method === APP_SERVER_NOTIFICATIONS.ITEM_STARTED) {
961962
this.mcp.capture(params);
962963
}
963964
if (method === APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED) {
964965
this.mcp.release(params);
965966
}
966967

968+
if (!isMainThread) return;
969+
970+
if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) {
971+
// Capture the active turn id (steer precondition / interrupt target).
972+
this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id);
973+
}
974+
967975
// codex auto-compaction surfaces as a contextCompaction item: item/started → in progress,
968976
// item/completed → boundary (codex emits no separate thread/compacted; that's a guarded
969977
// fallback). compactionActive dedupes to one boundary per compaction.
@@ -1437,6 +1445,12 @@ function mapTurnStopReason(status: string | undefined): StopReason {
14371445
return "end_turn";
14381446
}
14391447

1448+
function readNotificationThreadId(params: unknown): string | undefined {
1449+
if (!params || typeof params !== "object") return undefined;
1450+
const threadId = (params as { threadId?: unknown }).threadId;
1451+
return typeof threadId === "string" ? threadId : undefined;
1452+
}
1453+
14401454
/** The codex thread config override map: folds in MCP servers + makes extra workspace roots writable. Undefined when empty. */
14411455
function buildThreadConfig(
14421456
mcpServers: ReturnType<typeof toCodexMcpServers>,

0 commit comments

Comments
 (0)