diff --git a/apps/server/src/orchestration/subagentProjection.test.ts b/apps/server/src/orchestration/subagentProjection.test.ts index 46aeda09..2989914c 100644 --- a/apps/server/src/orchestration/subagentProjection.test.ts +++ b/apps/server/src/orchestration/subagentProjection.test.ts @@ -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( [], diff --git a/apps/server/src/orchestration/subagentProjection.ts b/apps/server/src/orchestration/subagentProjection.ts index 11978396..9889dbcf 100644 --- a/apps/server/src/orchestration/subagentProjection.ts +++ b/apps/server/src/orchestration/subagentProjection.ts @@ -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); diff --git a/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts b/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts index 5a45e3ac..811f1213 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.mapping.test.ts @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { EventId, + type OrchestrationSubagent, ProviderDriverKind, ProviderItemId, ThreadId, @@ -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", () => { @@ -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 = []; + const foldEvents = (events: ReturnType) => { + 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( { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 2f148e22..71f94372 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -20,7 +20,7 @@ import { type ProviderExternalThreadTranscriptMessage, ProviderInstanceId, type ProviderRuntimeEvent, - type SubagentMetadataUpdatedPayload, + SubagentMetadataUpdatedPayload, type ProviderRequestKind, type ProviderRealtimeAudioChunk, type ProviderSubagentTranscriptEntry, @@ -768,19 +768,17 @@ function toCanonicalItemType(raw: string | undefined | null): CanonicalItemType } function canonicalSubAgentActivityItem(item: CodexSubAgentActivityItem): Record { + // 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: { @@ -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) { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 592c9593..c470d997 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -20,6 +20,7 @@ import { type ProviderStartReviewResult, type ProviderTurnStartResult, type ProviderUserInputAnswers, + type SubagentMetadataUpdatedPayload, RuntimeMode, ThreadId, TurnId, @@ -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 @@ -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; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 2e9297d9..af3b2e7b 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -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", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 8368bc70..caa72bca 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1446,6 +1446,12 @@ function collectSubagentActivityRecords( if (isRootAgentPath(asTrimmedString(item.agentPath))) { continue; } + // Native interactions include messages that leave an idle child idle. + // Older snapshots labeled them running; only child lifecycle events can + // restart the agent, even when replaying those saved activities. + if (item.type === "subAgentActivity" && item.kind === "interacted") { + continue; + } const toolCallId = asTrimmedString(item.id) ??