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
10 changes: 7 additions & 3 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,9 +276,13 @@ partial representation are omitted rather than replayed over newer content. Agen
rendered as human input. Missing, omitted, or incomplete child history produces a parent advisory;
an empty transcript alone does not invent a gap.

Historical work-state outcomes remain deferred. Replayed content alone confirms neither current work
nor controls. Rejoining a loaded child still requires current provider parentage; copied fork history
can be displayed but cannot acquire the original runtime's subscriptions.
Replay reports the latest recorded child turn's known success, cancellation, or failure as historical
`idle` before the response. A missing or still-running turn invents no outcome. Legacy history reuses
its turn data; paginated history reads the latest turn summary separately, retaining available text
and reporting a gap if that summary is unavailable. Recorded outcomes do not enter the live foreground
cache, withdraw current interactions, confirm runtime ownership, or grant controls.
Rejoining a loaded child still requires current provider parentage; copied fork history can be displayed
but cannot acquire the original runtime's subscriptions.
After replay, current child work/configuration snapshots follow the actual response write, including whole
JSON-RPC batches. Parent setup operations serialize without waiting on each other's batch delivery.

Expand Down
21 changes: 16 additions & 5 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import type {
ThreadSourceKind,
ThreadItem,
ThreadItemEntry,
Turn,
TurnCompletedNotification,
TurnSteerResponse,
UserInput,
Expand Down Expand Up @@ -85,7 +86,8 @@ import {
import {findSupportedEffort} from "./ModelConfigOption";
import {disableUnsupportedSkills, withoutUnsupportedSkills} from "./UnsupportedSkills";
export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata";
export type RecordedSubagentItem = {item: ThreadItem; completed: boolean; turnId?: string};
/** A null turn marks unavailable outcome metadata, not a failed turn. */
export type RecordedSubagentEntry = {item: ThreadItem; completed: boolean; turnId?: string} | {turn: Turn | null};

/**
* The slice of `thread/resume` the session layer consumes, plus whether Codex
Expand Down Expand Up @@ -828,8 +830,8 @@ export class CodexAcpClient {
return null;
}

/** Lazy item reads let the caller restore verified live delivery before reading snapshots. */
async readSubagentHistory(threadId: string): Promise<{thread: Thread; items: AsyncIterable<RecordedSubagentItem[]>}> {
/** Lazy history reads let the caller restore verified live delivery before reading snapshots. */
async readSubagentHistory(threadId: string): Promise<{thread: Thread; items: AsyncIterable<RecordedSubagentEntry[]>}> {
const {thread} = await this.codexClient.threadRead({threadId});
if (thread.id !== threadId) throw new Error("Subagent history metadata names a different thread");
return {
Expand Down Expand Up @@ -1808,20 +1810,29 @@ async function* oneItemPage<T>(items: T[]): AsyncGenerator<T[]> {
if (items.length > 0) yield items;
}

async function* legacySubagentItems(client: CodexAppServerClient, threadId: string): AsyncGenerator<RecordedSubagentItem[]> {
async function* legacySubagentItems(client: CodexAppServerClient, threadId: string): AsyncGenerator<RecordedSubagentEntry[]> {
const {thread} = await client.threadRead({threadId, includeTurns: true});
if (thread.id !== threadId) throw new Error("Subagent history names a different thread");
yield thread.turns.flatMap(turn => turn.items.map(item => ({
item, turnId: turn.id, completed: recordedItemCompleted(item, turn.status !== "inProgress"),
})));
const turn = thread.turns.at(-1);
if (turn) yield [{turn}];
}

async function* paginatedSubagentItems(client: CodexAppServerClient, threadId: string): AsyncGenerator<RecordedSubagentItem[]> {
async function* paginatedSubagentItems(client: CodexAppServerClient, threadId: string): AsyncGenerator<RecordedSubagentEntry[]> {
for await (const page of client.threadItemEntryPages(threadId)) {
yield page.map(({item, turnId, completedAtMs}) => ({
item, turnId, completed: recordedItemCompleted(item, completedAtMs !== null),
}));
}
try {
const {data} = await client.threadTurnsList({threadId, limit: 1, sortDirection: "desc", itemsView: "notLoaded"});
if (data[0]) yield [{turn: data[0]}];
} catch (error) {
logger.error(`Failed to read recorded outcome for subagent ${threadId}`, error);
yield [{turn: null}];
}
}

function recordedItemCompleted(item: ThreadItem, completed: boolean): boolean {
Expand Down
14 changes: 10 additions & 4 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
OPENAI_PROVIDER_ID,
type SessionMetadata,
type SessionMetadataWithThread,
type RecordedSubagentItem,
type RecordedSubagentEntry,
type UrlElicitationRequester
} from "./CodexAcpClient";
import {
Expand Down Expand Up @@ -3702,7 +3702,7 @@ export class CodexAcpServer {

/** Recorded conversation data is independent of verified runtime ownership and current work. */
private async streamStandardHistory(
sessionId: string, pages: AsyncIterable<RecordedSubagentItem[]>, state: SessionState, isOpen: () => boolean,
sessionId: string, pages: AsyncIterable<RecordedSubagentEntry[]>, state: SessionState, isOpen: () => boolean,
threadId = sessionId, ancestry = new Set([threadId]), replayed = new Set<string>(), gaps = new Set<string>(),
): Promise<void> {
const router = state.subagents;
Expand Down Expand Up @@ -3744,8 +3744,14 @@ export class CodexAcpServer {
};
let unresolved: Array<ThreadItem & {type: "subAgentActivity"}> = [];
for await (const items of pages) {
for (const {item, completed, turnId} of items) {
for (const entry of items) {
if (!isOpen()) throw new SessionClosedDuringLoadError();
if ("turn" in entry) {
if (entry.turn === null) gaps.add(threadId);
else if (threadId !== state.sessionId) await router.replayTurnOutcome(threadId, entry.turn);
continue;
}
const {item, completed, turnId} = entry;
if (item.type === "subAgentActivity") {
if (isRootAgentPath(item.agentPath)) continue;
if (ancestry.has(item.agentThreadId)) { gaps.add(item.agentThreadId); continue; }
Expand Down Expand Up @@ -5586,7 +5592,7 @@ async function* itemsOfEntries(pages: AsyncIterable<ThreadItemEntry[]>): AsyncGe
}
}

async function* standardHistoryItems(pages: AsyncIterable<ThreadItem[]>): AsyncGenerator<RecordedSubagentItem[]> {
async function* standardHistoryItems(pages: AsyncIterable<ThreadItem[]>): AsyncGenerator<RecordedSubagentEntry[]> {
for await (const page of pages) {
yield page.map(item => ({item, completed: !("status" in item) || item.status !== "inProgress"}));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[
{
"sessionId": "thread-1",
"update": {
"sessionId": "thread-1:subagent:worker",
"state": {
"stopReason": "end_turn",
"state": "idle"
},
"sessionUpdate": "subagent_update"
}
}
]
17 changes: 17 additions & 0 deletions src/__tests__/CodexACPAgent/data/historical-outcome-v1-failed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[
{
"sessionId": "thread-1",
"update": {
"sessionId": "thread-1:subagent:worker",
"state": {
"error": {
"code": -32603,
"message": "Recorded failure"
},
"stopReason": "error",
"state": "idle"
},
"sessionUpdate": "subagent_update"
}
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[
{
"sessionId": "thread-1",
"update": {
"sessionId": "thread-1:subagent:worker",
"state": {
"stopReason": "cancelled",
"state": "idle"
},
"sessionUpdate": "subagent_update"
}
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[
{
"sessionId": "thread-1:subagent:worker",
"update": {
"stopReason": "end_turn",
"state": "idle",
"sessionUpdate": "state_update"
}
},
{
"sessionId": "thread-1",
"update": {
"sessionId": "thread-1:subagent:worker",
"state": {
"stopReason": "end_turn",
"state": "idle"
},
"sessionUpdate": "subagent_update"
}
}
]
29 changes: 29 additions & 0 deletions src/__tests__/CodexACPAgent/data/historical-outcome-v2-failed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
[
{
"sessionId": "thread-1:subagent:worker",
"update": {
"error": {
"code": -32603,
"message": "Recorded failure"
},
"stopReason": "error",
"state": "idle",
"sessionUpdate": "state_update"
}
},
{
"sessionId": "thread-1",
"update": {
"sessionId": "thread-1:subagent:worker",
"state": {
"error": {
"code": -32603,
"message": "Recorded failure"
},
"stopReason": "error",
"state": "idle"
},
"sessionUpdate": "subagent_update"
}
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[
{
"sessionId": "thread-1:subagent:worker",
"update": {
"stopReason": "cancelled",
"state": "idle",
"sessionUpdate": "state_update"
}
},
{
"sessionId": "thread-1",
"update": {
"sessionId": "thread-1:subagent:worker",
"state": {
"stopReason": "cancelled",
"state": "idle"
},
"sessionUpdate": "subagent_update"
}
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@
"sessionUpdate": "agent_message_chunk"
}
},
{
"sessionId": "thread-1:subagent:worker",
"update": {
"stopReason": "end_turn",
"state": "idle",
"sessionUpdate": "state_update"
}
},
{
"sessionId": "thread-1:subagent:worker",
"update": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,13 @@
},
"sessionUpdate": "agent_message_chunk"
}
},
{
"sessionId": "thread-1:subagent:worker",
"update": {
"stopReason": "end_turn",
"state": "idle",
"sessionUpdate": "state_update"
}
}
]
6 changes: 3 additions & 3 deletions src/__tests__/CodexACPAgent/standard-subagent-replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ describe.each([1, 2] as const)("ACP v%s recorded child replay", version => {
expect(gap()).toEqual([]);
});

it("reads paginated child items in order without requesting legacy turns or work outcomes", async () => {
it("retains ordered paginated items without legacy turns when the outcome is unavailable", async () => {
await connect();
const {reads} = history(h, {[sessionId]: {items: [spawn()]}, [worker]: {items: [], paginated: true}});
const entries = [message("first-page", "First page"), message("last-page", "Last page")].map(item =>
Expand All @@ -155,12 +155,12 @@ describe.each([1, 2] as const)("ACP v%s recorded child replay", version => {
if (sortDirection === "desc") return {data: [entries[1]], nextCursor: null, backwardsCursor: null};
return {data: cursor ? [entries[1]] : [entries[0]], nextCursor: cursor ? null : "second", backwardsCursor: null};
});
const outcomeRead = vi.fn(async () => { throw new Error("Outcomes are not part of this slice"); });
const outcomeRead = vi.fn(async () => { throw new Error("Outcome metadata unavailable"); });
h.setCodexResponse("thread/turns/list", outcomeRead);
await load();
await flush(h);
expect(reads.some(read => read.id === worker && read.includeTurns)).toBe(false);
expect(outcomeRead).not.toHaveBeenCalled();
expect(outcomeRead).toHaveBeenCalledExactlyOnceWith({threadId: worker, limit: 1, sortDirection: "desc", itemsView: "notLoaded"});
const ids = childUpdates().flatMap(entry => entry.update.sessionUpdate === "agent_message_chunk" ? [entry.update.messageId] : []);
expect(ids).toEqual(["first-page", "last-page"]);
});
Expand Down
Loading
Loading