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
63 changes: 63 additions & 0 deletions apps/server/src/orchestration/subagentProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,69 @@ describe("projectSubagentActivity", () => {
expect(orphan[1]?.resultBody).toBe("Lost agent output.");
});

it("keeps a completed Codex agent settled after a message and reopens on a real child turn", () => {
const completed = projectSubagentActivity(
[],
activity({
id: "child-result",
kind: "subagent.result",
turnId: TURN_ID,
payload: {
itemType: "collab_agent_tool_call",
data: {
item: {
tool: "wait",
receiverThreadIds: ["child"],
agentsStates: { child: { status: "completed", message: "Review complete." } },
},
},
},
}),
);
const messaged = projectSubagentActivity(
completed,
activity({
id: "child-message",
kind: "tool.completed",
turnId: RESUME_TURN_ID,
payload: {
itemType: "collab_agent_tool_call",
data: {
item: {
type: "subAgentActivity",
kind: "interacted",
agentThreadId: "child",
tool: "sendInput",
status: "inProgress",
agentsStates: { child: { status: "running" } },
},
},
},
}),
);
expect(messaged).toEqual(completed);
const resumed = projectSubagentActivity(
messaged,
activity({
id: "child-turn-started",
kind: "subagent.metadata",
turnId: RESUME_TURN_ID,
payload: { agentThreadId: "child", status: "running" },
}),
);
expect(resumed).toMatchObject([{ status: "running", turnId: RESUME_TURN_ID }]);
const settled = projectSubagentActivity(
resumed,
activity({
id: "child-turn-completed",
kind: "subagent.metadata",
turnId: RESUME_TURN_ID,
payload: { agentThreadId: "child", status: "completed" },
}),
);
expect(settled).toMatchObject([{ status: "completed", resultBody: "Review complete." }]);
});

it("still folds Codex-shaped collab items", () => {
const roster = projectSubagentActivity(
[],
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration/subagentProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ function collabPatches(activity: OrchestrationThreadActivity): SubagentPatch[] {
})
: null);
if (!item) return [];
// Older adapters fabricated "running" for this notification, including
// messages to finished agents. Ignore it when replaying those saved rows too.
if (item.type === "subAgentActivity" && item.kind === "interacted") return [];
if (isRootAgentPath(text(item.agentPath))) return [];
const tool = text(item.tool);
const nativeAgentId = text(item.agentThreadId);
Expand Down
98 changes: 98 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from "node:assert/strict";

import {
EventId,
type OrchestrationSubagent,
ProviderDriverKind,
ProviderItemId,
ThreadId,
Expand All @@ -16,6 +17,9 @@ import {
mapToRuntimeEvents,
readCodexSubagentParentThreadId,
} from "./CodexAdapter.ts";
import { projectRuntimeEventToActivities } from "../../orchestration/Layers/ProviderActivityProjection.ts";
import { projectSubagentActivity } from "../../orchestration/subagentProjection.ts";
import { readCollabChildTurnStatus, type CodexServerNotification } from "./CodexSessionRuntime.ts";

describe("CodexAdapter item mapping", () => {
it("maps structured automatic approval review outcomes", () => {
Expand Down Expand Up @@ -178,6 +182,100 @@ describe("CodexAdapter item mapping", () => {
}
});

it("tracks actual child turns without restarting a finished agent on message delivery", () => {
const parentThreadId = ThreadId.make("thread-parent");
const parentTurnId = TurnId.make("turn-parent");
const eventBase = {
kind: "notification",
provider: ProviderDriverKind.make("codex"),
createdAt: "2026-09-05T06:40:10.000Z",
threadId: parentThreadId,
turnId: parentTurnId,
} as const;
let roster: ReadonlyArray<OrchestrationSubagent> = [];
const foldEvents = (events: ReturnType<typeof mapToRuntimeEvents>) => {
for (const activity of events.flatMap((event) => projectRuntimeEventToActivities(event))) {
roster = projectSubagentActivity(roster, activity);
}
};
const nativeActivity = (kind: "started" | "interacted") =>
mapToRuntimeEvents(
{
...eventBase,
id: EventId.make(`native-${kind}`),
method: "item/completed",
payload: {
completedAtMs: 1_788_590_410_000,
threadId: "provider-parent",
turnId: "provider-parent-turn",
item: {
id: `native-${kind}`,
type: "subAgentActivity",
kind,
agentThreadId: "provider-child",
agentPath: "/root/github_install_order",
},
},
},
parentThreadId,
);
const childLifecycle = (notification: CodexServerNotification) => {
const metadata = readCollabChildTurnStatus(notification);
assert.ok(metadata);
const events = mapToRuntimeEvents(
{
...eventBase,
id: EventId.make(`child-${notification.method}`),
method: "subagent/status/changed",
providerThreadId: "provider-child",
payload: metadata,
},
parentThreadId,
);
assert.deepStrictEqual(
events.map((event) => event.type),
["subagent.metadata.updated"],
);
foldEvents(events);
assert.equal(roster.length, 1);
assert.equal(roster[0]?.agentThreadId, "provider-child");
};

foldEvents(nativeActivity("started"));
childLifecycle({
method: "turn/started",
params: {
threadId: "provider-child",
turn: { id: "child-turn", status: "inProgress", items: [] },
},
});
assert.equal(roster[0]?.status, "running");

for (const status of ["completed", "failed", "interrupted"] as const) {
childLifecycle({
method: "turn/completed",
params: {
threadId: "provider-child",
turn: { id: "child-turn", status, items: [] },
},
});
assert.equal(roster[0]?.status, status);

foldEvents(nativeActivity("interacted"));
assert.equal(roster.length, 1);
assert.equal(roster[0]?.status, status);

childLifecycle({
method: "turn/started",
params: {
threadId: "provider-child",
turn: { id: "child-followup-turn", status: "inProgress", items: [] },
},
});
assert.equal(roster[0]?.status, "running");
}
});

it("maps explicit spawn settings without retaining the prompt message", () => {
const [metadataEvent] = mapToRuntimeEvents(
{
Expand Down
27 changes: 19 additions & 8 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
type ProviderExternalThreadTranscriptMessage,
ProviderInstanceId,
type ProviderRuntimeEvent,
type SubagentMetadataUpdatedPayload,
SubagentMetadataUpdatedPayload,
type ProviderRequestKind,
type ProviderRealtimeAudioChunk,
type ProviderSubagentTranscriptEntry,
Expand Down Expand Up @@ -768,19 +768,17 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType
}

function canonicalSubAgentActivityItem(item: CodexSubAgentActivityItem): Record<string, unknown> {
// A message to an idle agent also emits "interacted". Only the child's
// turn lifecycle can tell us whether that interaction started more work.
if (item.kind === "interacted") return item;
// "completed" (Codex 0.150+) and "interrupted" both end the agent; only the
// first is a clean finish.
const running = item.kind === "started" || item.kind === "interacted";
const running = item.kind === "started";
return {
...item,
// Keep the native fields above while exposing the stable collab-agent
// fields consumed by projections shared across providers.
tool:
item.kind === "started"
? "spawnAgent"
: item.kind === "interacted"
? "sendInput"
: "closeAgent",
tool: item.kind === "started" ? "spawnAgent" : "closeAgent",
status: running ? "inProgress" : "completed",
receiverThreadIds: [item.agentThreadId],
agentsStates: {
Expand Down Expand Up @@ -1628,6 +1626,19 @@ export function mapToRuntimeEvents(
];
}

if (event.method === "subagent/status/changed") {
const metadata = readPayload(SubagentMetadataUpdatedPayload, event.payload);
return metadata
? [
{
...runtimeEventBase(event, canonicalThreadId),
type: "subagent.metadata.updated",
payload: metadata,
},
]
: [];
}

if (event.method === "thread/settings/updated") {
const metadata = childSettingsMetadata(event.payload);
if (!metadata) {
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/provider/Layers/CodexSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type ProviderStartReviewResult,
type ProviderTurnStartResult,
type ProviderUserInputAnswers,
type SubagentMetadataUpdatedPayload,
RuntimeMode,
ThreadId,
TurnId,
Expand Down Expand Up @@ -1321,6 +1322,22 @@ export function enrichCollabAgentToolPayload(
};
}

/** Converts a child's real turn lifecycle into a roster update. The caller
* must establish that the notification belongs to a known child first. */
export function readCollabChildTurnStatus(
notification: CodexServerNotification,
): SubagentMetadataUpdatedPayload | undefined {
if (notification.method === "turn/started") {
return { agentThreadId: notification.params.threadId, status: "running" };
}
if (notification.method === "turn/completed") {
const status = notification.params.turn.status;
if (status === "inProgress") return undefined;
return { agentThreadId: notification.params.threadId, status };
}
return undefined;
}

/** Child-conversation notifications that describe the child's own turn rather
* than work done inside the parent's. Mapped onto the parent turn they would
* overwrite the parent's state: a child's `turn/diff/updated` covers only its
Expand Down Expand Up @@ -1613,6 +1630,19 @@ export const makeCodexSessionRuntime = (
providerThreadId,
);
if (childParentTurnId && shouldSuppressChildConversationNotification(notification.method)) {
// Keep child turns out of the parent's lifecycle, but use them to
// track real work after a follow-up instead of guessing from messages.
const childStatus = readCollabChildTurnStatus(notification);
if (childStatus) {
yield* emitEvent({
kind: "notification",
threadId: options.threadId,
method: "subagent/status/changed",
...(providerConversationId ? { providerThreadId: providerConversationId } : {}),
turnId: childParentTurnId,
payload: childStatus,
});
}
yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns);
yield* Ref.set(collabChildThreadMetadataRef, collabChildThreadMetadata);
return;
Expand Down
85 changes: 85 additions & 0 deletions apps/web/src/session-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5265,6 +5265,91 @@ describe("deriveSubagentProgressState", () => {
).toBeNull();
});

it("keeps a finished Codex agent idle after a message until its child turn starts", () => {
const activities = [
makeActivity({
id: "child-result",
createdAt: "2026-09-05T06:38:52.000Z",
kind: "subagent.result",
turnId: "turn-1",
payload: {
itemType: "collab_agent_tool_call",
status: "completed",
data: {
item: {
id: "subagent-response:agent-child",
type: "collabAgentToolCall",
tool: "wait",
status: "completed",
receiverThreadIds: ["agent-child"],
agentsStates: {
"agent-child": { status: "completed", message: "Review complete." },
},
},
},
},
}),
// Saved activities from older servers claim that any interaction starts
// work, including a send_message delivered after the child's final reply.
makeActivity({
id: "child-message",
createdAt: "2026-09-05T06:40:10.000Z",
kind: "tool.completed",
turnId: "turn-2",
payload: {
itemType: "collab_agent_tool_call",
status: "completed",
data: {
item: {
id: "message-call",
type: "subAgentActivity",
kind: "interacted",
agentThreadId: "agent-child",
agentPath: "/root/review",
tool: "sendInput",
status: "inProgress",
receiverThreadIds: ["agent-child"],
agentsStates: { "agent-child": { status: "running" } },
},
},
},
}),
];
const settled = deriveSubagentActivityState({
activities,
latestTurnId: TurnId.make("turn-2"),
latestTurnSettled: true,
});

expect(settled.progress).toBeNull();
expect(settled.history).toMatchObject([
{ item: { status: "completed", turnId: "turn-1" }, resultBody: "Review complete." },
]);
expect(settled.resultEntries).toMatchObject([{ body: "Review complete." }]);
expect(settled.liveEntries).toEqual([]);

const resumed = deriveSubagentActivityState({
activities: [
...activities,
makeActivity({
id: "child-turn-started",
createdAt: "2026-09-05T06:41:00.000Z",
kind: "subagent.metadata",
turnId: "turn-2",
payload: { agentThreadId: "agent-child", status: "running" },
}),
],
latestTurnId: TurnId.make("turn-2"),
latestTurnSettled: true,
});

expect(resumed.progress).toMatchObject({
activeCount: 1,
items: [{ agentThreadId: "agent-child", status: "running" }],
});
expect(resumed.history).toHaveLength(1);
});

it("does not expose root conversation interactions as running subagents", () => {
const childStarted = makeActivity({
id: "native-child-started",
Expand Down
Loading
Loading