Compare commits

..
Author SHA1 Message Date
Mohamed Boudra bb16d3d4a0 test(server): run reopened Codex agent tests without the Codex CLI installed 2026-10-28 16:32:00 +01:00
Mohamed Boudra ec5b5e0a31 fix(server): read Codex history only when Paseo replays it
Reopening a Codex agent whose timeline Paseo still held read the full
thread on connect and kept it on the session until the next replay, which
never came, so every reopened agent held a second copy of its transcript.
thread/resume also returned every turn, and only the sandbox policy was
used from it.

Resume now sends excludeTurns (older Codex ignores the unknown field) and
the history read moves into streamHistory, whose caller owns the result.
A resumed session still needs the earlier turns for rewind targets and the
sub-agent routes that keep an earlier child's interactions on its original
card, so the first turn, goal or rewind indexes the thread and drops the
timeline it read.
2026-10-28 16:14:39 +01:00
12 changed files with 368 additions and 489 deletions
@@ -422,237 +422,6 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
});
});
describe("a background subagent", () => {
// Shapes recorded from Claude Code 2.1.x: the launch returns at once with an async result,
// and a child stopped by an interrupt ends on the interrupt placeholder, not an end_turn.
function backgroundLaunch(): string[] {
return [
parentEntry([
{
type: "tool_use",
id: TOOL_USE_ID,
name: "Agent",
input: {
subagent_type: "general-purpose",
description: "probe sleeper",
run_in_background: true,
},
},
]),
JSON.stringify({
type: "user",
sessionId: "replay-session",
timestamp: "2026-07-26T06:27:48.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: TOOL_USE_ID,
content: [
{
type: "text",
text: `Async agent launched successfully.\nagentId: ${AGENT_ID}`,
},
],
},
],
},
toolUseResult: { isAsync: true, status: "async_launched", agentId: AGENT_ID },
}),
];
}
function readStatus(event: { status?: string }): string | undefined {
return event.status;
}
function interruptedChild(): string[] {
return [
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:27:50.000Z",
message: {
role: "assistant",
content: [{ type: "tool_use", id: "toolu_child_bash", name: "Bash", input: {} }],
stop_reason: "tool_use",
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.000Z",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_child_bash",
content: "The user doesn't want to proceed with this tool use.",
is_error: true,
},
],
},
}),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:28:05.001Z",
message: {
role: "user",
content: [{ type: "text", text: "[Request interrupted by user for tool use]" }],
},
}),
];
}
test.each([
["the meta sidecar", JSON.stringify({ toolUseId: TOOL_USE_ID })],
["the legacy scrape", null],
])("is canceled when an interrupt stopped it, linked through %s", async (_link, meta) => {
writeSession({ parentLines: backgroundLaunch(), meta, sidechainLines: interruptedChild() });
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: "canceled",
});
});
test("stays running when the child was given a new prompt after the interrupt", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [
...interruptedChild(),
JSON.stringify({
type: "user",
isSidechain: true,
agentId: AGENT_ID,
timestamp: "2026-07-26T06:29:00.000Z",
message: { role: "user", content: [{ type: "text", text: "Carry on with the task." }] },
}),
],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("canceled");
expect(statuses).toContain("running");
});
test.each(["interrupted", "running"])(
"ignores an API request copy of a %s child's launch result",
async (state) => {
const launch = backgroundLaunch();
const result = JSON.parse(launch[1]!);
writeSession({
parentLines: [
...launch,
JSON.stringify({
type: "api-request-blob",
sessionId: "replay-session",
hash: "request-copy",
message: result.message,
}),
],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines:
state === "interrupted"
? interruptedChild()
: [sidechainEntry({ stopReason: "tool_use" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: state === "interrupted" ? "canceled" : "running",
});
},
);
test("stays running while the child has not finished", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "tool_use" })],
});
const statuses = upserts(await replayDescriptors()).map(readStatus);
expect(statuses).not.toContain("completed");
expect(statuses).toContain("running");
});
function taskNotification(status: string, form = "user"): string {
const text = `<task-notification>\n<task-id>${AGENT_ID}</task-id>\n<tool-use-id>${TOOL_USE_ID}</tool-use-id>\n<status>${status}</status>\n<summary>Background task ended</summary>\n</task-notification>`;
const records = {
user: { message: { role: "user", content: [{ type: "text", text }] } },
system: {
subtype: "task_notification",
task_id: AGENT_ID,
tool_use_id: TOOL_USE_ID,
status,
},
"queue-operation": { operation: "enqueue", content: text },
};
return JSON.stringify({
type: form,
timestamp: "2026-07-26T06:29:00.000Z",
...records[form as keyof typeof records],
});
}
for (const form of ["user", "system", "queue-operation"]) {
test.each([
["completed", "completed"],
["failed", "failed"],
["killed", "canceled"],
["stopped", "canceled"],
])(
`restores a %s task notification from ${form} when the child stop_reason is null`,
async (status, expected) => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification(status, form)],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({
id: TOOL_USE_ID,
status: expected,
});
},
);
}
test("prefers a stopped notification over the child's end_turn", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("stopped")],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "canceled" });
});
test("does not invent an outcome for an unknown notification status", async () => {
writeSession({
parentLines: [...backgroundLaunch(), taskNotification("unknown")],
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: null })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "running" });
});
test("completes from the child's own end_turn", async () => {
writeSession({
parentLines: backgroundLaunch(),
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [sidechainEntry({ stopReason: "end_turn" })],
});
expect(upserts(await replayDescriptors()).at(-1)).toMatchObject({ status: "completed" });
});
});
test("does not replay a skill-spawned subagent the parent never named", async () => {
// The reported bug. A /code-review subagent has no Task tool_use, so its sidecar carries only
// agentType and its id appears nowhere in the parent — it used to replay as running forever,
@@ -33,9 +33,7 @@ import {
mapTaskNotificationSystemRecordToToolCall,
mapTaskNotificationUserContentToToolCall,
readTaskNotificationToolUseIdFromHistoryRecord,
readTaskNotificationFromHistoryRecord,
} from "./task-notification-tool-call.js";
import { isClaudeInterruptPlaceholderText } from "./interrupt-placeholder.js";
import {
getClaudeContextWindowMaxTokens,
getClaudeModelsWithSettings,
@@ -58,8 +56,6 @@ import {
type ClaudeHookObservationInput,
} from "./subagents/live-source.js";
import { observeReplaySubagents, type ClaudeReplayParentFacts } from "./subagents/replay-source.js";
import { mapTaskStatus } from "./subagents/task-status.js";
import type { ProviderSubagentStatus } from "../../provider-subagents/store.js";
import { foldSubagentObservations, type SubagentObservation } from "./subagents/observation.js";
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
@@ -429,6 +425,7 @@ const CLAUDE_ROOT_ONLY_COMMANDS = new Set([
"usage",
]);
const INTERRUPT_TOOL_USE_PLACEHOLDER = "[Request interrupted by user for tool use]";
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
const NO_RESPONSE_REQUESTED_PLACEHOLDER = "No response requested.";
const STEER_SUPERSEDED_PERMISSION_MESSAGE =
"The user answered with a message instead of approving. Their message follows.";
@@ -792,6 +789,11 @@ function normalizeClaudeTranscriptText(value: unknown): string | null {
return normalized.length > 0 ? normalized : null;
}
function isClaudeInterruptPlaceholderText(value: unknown): boolean {
const normalized = normalizeClaudeTranscriptText(value);
return normalized !== null && INTERRUPT_PLACEHOLDER_PATTERN.test(normalized);
}
function isClaudeNoResponsePlaceholderText(value: unknown): boolean {
return normalizeClaudeTranscriptText(value) === NO_RESPONSE_REQUESTED_PLACEHOLDER;
}
@@ -6260,34 +6262,33 @@ async function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
): Promise<ClaudeReplayParentFacts> {
const toolCalls = new Map<string, { title?: string; description?: string }>();
const linksByAgentId = new Map<string, { toolCallId: string }>();
const outcomes: Map<string, { status: ProviderSubagentStatus }> = new Map();
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>();
const outcomes = new Map<string, { failed: boolean }>();
for await (const entry of entries) {
const notification = readTaskNotificationFromHistoryRecord(entry);
const status = mapTaskStatus(notification?.status ?? undefined);
if (notification?.toolUseId && status) {
outcomes.set(notification.toolUseId, { status });
}
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
const block = toObjectRecord(value);
const declaration = readClaudeReplayTaskDeclaration(entry.type, block);
if (declaration) toolCalls.set(declaration.id, declaration.identity);
if (
entry.type !== "user" ||
block?.type !== "tool_result" ||
typeof block.tool_use_id !== "string"
)
continue;
if (toObjectRecord(entry.toolUseResult)?.status !== "async_launched")
outcomes.set(block.tool_use_id, {
status: block.is_error === true ? "failed" : "completed",
block?.type === "tool_use" &&
(block.name === "Task" || block.name === "Agent") &&
typeof block.id === "string"
) {
const input = toObjectRecord(block.input);
const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type);
const description = readNonEmptyString(input?.description);
toolCalls.set(block.id, {
...(title ? { title } : {}),
...(description ? { description } : {}),
});
}
if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
outcomes.set(block.tool_use_id, { failed: block.is_error === true });
const agentId = readLegacyClaudeSubagentId(block.content);
if (agentId)
linksByAgentId.set(agentId, {
toolCallId: block.tool_use_id,
failed: block.is_error === true,
});
}
}
@@ -6298,29 +6299,6 @@ async function readClaudeReplayParentFacts(
};
}
function readClaudeReplayTaskDeclaration(
entryType: unknown,
block: Record<string, unknown> | undefined,
): {
id: string;
identity: { title?: string; description?: string };
} | null {
if (
entryType !== "assistant" ||
block?.type !== "tool_use" ||
(block.name !== "Task" && block.name !== "Agent") ||
typeof block.id !== "string"
)
return null;
const input = toObjectRecord(block.input);
const title = readNonEmptyString(input?.name) ?? readNonEmptyString(input?.subagent_type);
const description = readNonEmptyString(input?.description);
return {
id: block.id,
identity: { ...(title ? { title } : {}), ...(description ? { description } : {}) },
};
}
function readLegacyClaudeSubagentId(content: unknown): string | undefined {
return /agentId:\s*([\w-]+)/.exec(JSON.stringify(content))?.[1];
}
@@ -1,6 +0,0 @@
const INTERRUPT_PLACEHOLDER_PATTERN = /^\[Request interrupted by user(?:[^\]]*)\]$/;
/** The text Claude Code records in place of a turn that an interrupt stopped. */
export function isClaudeInterruptPlaceholderText(value: unknown): boolean {
return typeof value === "string" && INTERRUPT_PLACEHOLDER_PATTERN.test(value.trim());
}
@@ -1,8 +1,7 @@
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { mapTaskStatus } from "./task-status.js";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { AgentMetadata } from "../../../agent-sdk-types.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js";
import {
@@ -121,6 +120,28 @@ function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
/**
* The descriptor has no paused or pending state, and neither is terminal, so both read as
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
*/
function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
switch (status) {
case "pending":
case "running":
case "paused":
return "running";
case "completed":
return "completed";
case "failed":
return "failed";
case "killed":
case "stopped":
return "canceled";
default:
return undefined;
}
}
export interface ClaudeTaskProtocolSourceInput {
/**
* The parent's Task tool input, by tool_use id.
@@ -36,7 +36,7 @@ function parentWithTaskCall(failed = false): ClaudeReplayParentFacts {
],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: failed ? "failed" : "completed" }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { failed }]]),
};
}
@@ -117,7 +117,7 @@ describe("observeReplaySubagents", () => {
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { status: "completed" }]]),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
},
},
{
@@ -173,8 +173,8 @@ describe("observeReplaySubagents", () => {
it("falls back to the scraped link when there is no meta file", async () => {
const parent: ClaudeReplayParentFacts = {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "completed" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
outcomesByToolCallId: new Map(),
};
const [declared, ...rest] = (
@@ -263,8 +263,8 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID }]]),
outcomesByToolCallId: new Map([[TOOL_USE_ID, { status: "failed" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
})
@@ -3,7 +3,6 @@ import { z } from "zod";
import type { AgentTimelineItem } from "../../../agent-sdk-types.js";
import { normalizeProviderReplayTimestamp } from "../../../provider-history-timestamps.js";
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
import { isClaudeInterruptPlaceholderText } from "../interrupt-placeholder.js";
import { resolveObservedClaudeModelId } from "../models.js";
import type { SubagentObservation } from "./observation.js";
import { buildClaudeSubagentSubtitle, type ClaudeSubagentUsage } from "./presentation.js";
@@ -140,12 +139,9 @@ export interface ClaudeReplayParentFacts {
/** Task `tool_use` id -> identity declared in the parent's tool input. */
toolCalls: ReadonlyMap<string, { title?: string; description?: string }>;
/** agentId -> tool call, recovered by scraping tool-result text. Legacy fallback only. */
linksByAgentId: ReadonlyMap<string, { toolCallId: string }>;
/**
* Task `tool_use` id -> outcome, whichever way the child was linked. A background launch has
* none: its immediate result only says the child started.
*/
outcomesByToolCallId: ReadonlyMap<string, { status: ProviderSubagentStatus }>;
linksByAgentId: ReadonlyMap<string, { toolCallId: string; failed: boolean }>;
/** Task `tool_use` id -> outcome. Usable once meta.json supplies the link directly. */
outcomesByToolCallId: ReadonlyMap<string, { failed: boolean }>;
}
interface ParentLink {
@@ -181,14 +177,27 @@ function resolveParentLink(
parent: ClaudeReplayParentFacts,
): ParentLink | null {
const metaToolUseId = subagent.meta?.toolUseId?.trim();
const toolCallId =
metaToolUseId && parent.toolCalls.has(metaToolUseId)
? metaToolUseId
: parent.linksByAgentId.get(subagent.agentId)?.toolCallId;
if (!toolCallId || !parent.toolCalls.has(toolCallId)) return null;
if (metaToolUseId && parent.toolCalls.has(metaToolUseId)) {
const outcome = parent.outcomesByToolCallId.get(metaToolUseId);
let status: ProviderSubagentStatus | null = null;
if (outcome) status = outcome.failed ? "failed" : "completed";
return {
id: metaToolUseId,
toolCallId: metaToolUseId,
status,
};
}
const status = parent.outcomesByToolCallId.get(toolCallId)?.status ?? null;
return { id: toolCallId, toolCallId, status };
const scraped = parent.linksByAgentId.get(subagent.agentId);
if (scraped && parent.toolCalls.has(scraped.toolCallId)) {
return {
id: scraped.toolCallId,
toolCallId: scraped.toolCallId,
status: scraped.failed ? "failed" : "completed",
};
}
return null;
}
/**
@@ -267,7 +276,9 @@ async function observeSubagent(
lastTimestamp = timestamp;
Object.assign(runtime, readRuntime(entry));
usage = readUsage(entry) ?? usage;
childTerminalStatus = readChildTerminalStatus(entry, childTerminalStatus);
if (entry.type === "assistant") {
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
recordReplayToolOwners(toolOwners, entry, link.id);
for (const item of await convertEntry(entry)) {
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
@@ -297,30 +308,6 @@ async function observeSubagent(
return observations;
}
/** Advance the child's fallback outcome without retaining transcript entries. */
function readChildTerminalStatus(
entry: ClaudeReplayEntry,
previous: ProviderSubagentStatus | null,
): ProviderSubagentStatus | null {
if (entry.type === "assistant") {
return entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
if (entry.type !== "user") return previous;
const texts = readUserTexts(entry);
if (texts.length === 0) return previous;
return texts.some(isClaudeInterruptPlaceholderText) ? "canceled" : null;
}
/** Tool results carry no user text and do not change the child's terminal status. */
function readUserTexts(entry: ClaudeReplayEntry): string[] {
const content = entry.message?.content;
if (typeof content === "string") return [content];
if (!Array.isArray(content)) return [];
return content.flatMap((block: { type?: unknown; text?: unknown } | null) =>
block?.type === "text" && typeof block.text === "string" ? [block.text] : [],
);
}
function recordReplayToolOwners(
owners: Map<string, string>,
entry: ClaudeReplayEntry,
@@ -1,23 +0,0 @@
import type { ProviderSubagentStatus } from "../../../provider-subagents/store.js";
/**
* The descriptor has no paused or pending state, and neither is terminal, so both read as
* running. `killed` is a cancellation, not a failure — the child was stopped, it did not error.
*/
export function mapTaskStatus(status: string | undefined): ProviderSubagentStatus | undefined {
switch (status) {
case "pending":
case "running":
case "paused":
return "running";
case "completed":
return "completed";
case "failed":
return "failed";
case "killed":
case "stopped":
return "canceled";
default:
return undefined;
}
}
@@ -407,21 +407,19 @@ export function mapTaskNotificationSystemRecordToToolCall(
}
export function readTaskNotificationToolUseIdFromHistoryRecord(record: unknown): string | null {
return readTaskNotificationFromHistoryRecord(record)?.toolUseId ?? null;
}
export function readTaskNotificationFromHistoryRecord(
record: unknown,
): TaskNotificationEnvelope | null {
const parsedRecord = TaskNotificationHistoryRecordSchema.safeParse(record);
if (!parsedRecord.success) return null;
if (parsedRecord.data.type === "user" && parsedRecord.data.message) {
return parseTaskNotificationFromUserContent({
content: parsedRecord.data.message.content,
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
});
if (!parsedRecord.success) {
return null;
}
return parseTaskNotificationFromSystemRecord(record);
if (parsedRecord.data.type === "user" && parsedRecord.data.message) {
return (
parseTaskNotificationFromUserContent({
content: parsedRecord.data.message.content,
messageId: parsedRecord.data.uuid ?? parsedRecord.data.message_id,
})?.toolUseId ?? null
);
}
return parseTaskNotificationFromSystemRecord(record)?.toolUseId ?? null;
}
export function coerceTaskNotificationHistoryRecordToSystemMessage(
@@ -16,6 +16,7 @@ import type {
AgentSessionConfig,
AgentSlashCommand,
AgentStreamEvent,
AgentTimelineItem,
} from "../agent-sdk-types.js";
import {
buildCodexAppServerEnv,
@@ -114,7 +115,6 @@ interface CodexSessionTestAccess {
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): Promise<void>;
loadPersistedHistory(client: CodexClientLike | null): Promise<void>;
refreshResolvedCollaborationMode(): void;
serviceTier: "fast" | null;
planModeEnabled: boolean;
@@ -503,6 +503,81 @@ function archivedThreadErrorMessage(threadId: string): string {
);
}
function codexUserItem(id: string, text: string) {
return { type: "userMessage", id, content: [{ type: "text", text }] };
}
function codexAgentItem(text: string) {
return { type: "agentMessage", id: `agent-${text}`, text };
}
// An AgentManager over a real Codex provider whose every spawn gets a fresh fake
// app-server serving `threads` from thread/read.
function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
const appServers: FakeCodexAppServer[] = [];
const provider = new CodexAppServerAgentClient(createTestLogger());
const internals = castInternals<{
goalsEnabledPromise: Promise<boolean> | null;
autoReviewEnabledPromise: Promise<boolean> | null;
spawnAppServer: () => Promise<ChildProcessWithoutNullStreams>;
}>(provider);
internals.goalsEnabledPromise = Promise.resolve(false);
internals.autoReviewEnabledPromise = Promise.resolve(false);
internals.spawnAppServer = async () => {
const appServer = createFakeCodexAppServer({
"thread/read": (params) => {
const { threadId } = params as { threadId: string };
return { thread: { id: threadId, turns: threads[threadId] ?? [] } };
},
});
appServers.push(appServer);
return appServer.child;
};
// The fake app-server stands in for the Codex CLI, which test machines may not have.
const client: AgentClient = {
provider: "codex",
capabilities: provider.capabilities,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
createSession: async () => {
throw new Error("Unexpected create");
},
resumeSession: (...args) => provider.resumeSession(...args),
};
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
const agentId = "00000000-0000-4000-8000-000000000001";
const handle = { provider: "codex", sessionId: "saved-thread" };
const config = createConfig({ cwd: process.cwd() });
async function open(): Promise<void> {
await manager.resumeAgentFromPersistence(handle, config, agentId);
await manager.hydrateTimelineFromProvider(agentId);
}
return {
manager,
agentId,
latestAppServer: () => appServers[appServers.length - 1],
threadMethods: (index: number) =>
appServers[index]
.requests()
.map((request) => request.method)
.filter((method) => typeof method === "string" && method.startsWith("thread/")),
// Cold-loads the agent, closes its runtime, and reopens it while Paseo keeps its timeline.
async loadCloseAndReopen(): Promise<AgentTimelineItem[]> {
await open();
const projected = manager.getTimeline(agentId);
await manager.closeAgent(agentId);
await open();
return projected;
},
async close(): Promise<void> {
await manager.closeAgent(agentId);
for (const appServer of appServers) appServer.assertNoErrors();
},
};
}
function asInternals(session: CodexTestSession): CodexSessionTestAccess {
return castInternals<CodexSessionTestAccess>(session);
}
@@ -1897,12 +1972,147 @@ describe("Codex app-server provider", () => {
"thread/resume",
"thread/unarchive",
"thread/resume",
"thread/read",
]);
await session.close();
appServer.assertNoErrors();
});
test("resumes a Codex thread without asking for its turns", async () => {
const appServer = createFakeCodexAppServer();
const provider = createProviderWithFakeAppServer(appServer);
const session = await provider.resumeSession({ sessionId: "saved-thread" });
expect(
appServer
.requests()
.filter((request) => request.method === "thread/resume")
.map((request) => request.params),
).toEqual([expect.objectContaining({ threadId: "saved-thread", excludeTurns: true })]);
await session.close();
appServer.assertNoErrors();
});
test("reopening an agent whose timeline Paseo still holds does not read Codex history", async () => {
const codex = createReopenableCodexAgent({
"saved-thread": [
{ id: "turn-1", items: [codexUserItem("user-1", "Hello"), codexAgentItem("Hi there.")] },
],
});
try {
const projected = await codex.loadCloseAndReopen();
expect(projected).toEqual([
expect.objectContaining({ type: "user_message", messageId: "user-1", text: "Hello" }),
expect.objectContaining({ type: "assistant_message", text: "Hi there." }),
]);
expect(codex.threadMethods(0)).toEqual([
"thread/loaded/list",
"thread/resume",
"thread/read",
]);
expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]);
expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected);
} finally {
await codex.close();
}
});
test("rewinds an agent reopened without reading its Codex history", async () => {
const firstTurn = {
id: "turn-1",
items: [codexUserItem("user-1", "First"), codexAgentItem("First answer.")],
};
const codex = createReopenableCodexAgent({
"saved-thread": [
firstTurn,
{ id: "turn-2", items: [codexUserItem("user-2", "Second"), codexAgentItem("Second.")] },
],
"forked-thread": [firstTurn],
});
try {
await codex.loadCloseAndReopen();
await codex.manager.rewind(codex.agentId, "user-2", "conversation");
expect(codex.latestAppServer().recordedRollbacks).toEqual([
{ threadId: "forked-thread", numTurns: 1 },
]);
expect(codex.manager.getTimeline(codex.agentId)).toEqual([
expect.objectContaining({ type: "user_message", messageId: "user-1", text: "First" }),
expect.objectContaining({ type: "assistant_message", text: "First answer." }),
]);
} finally {
await codex.close();
}
});
test("keeps a reopened agent's interaction with an earlier sub-agent on its original card", async () => {
const codex = createReopenableCodexAgent({
"saved-thread": [
{
id: "turn-1",
items: [
codexUserItem("user-1", "Delegate"),
{
type: "subAgentActivity",
id: "spawn-child",
kind: "started",
agentThreadId: "child-thread",
agentPath: "/root/child",
},
],
},
],
"child-thread": [{ id: "child-turn-1", items: [codexAgentItem("Child started.")] }],
});
try {
await codex.loadCloseAndReopen();
const appServer = codex.latestAppServer();
const run = codex.manager.runAgent(codex.agentId, "Ask the child again");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "saved-thread", turnId: "turn-2" });
appServer.beginsSubAgentActivity({
callId: "interact-child",
threadId: "child-thread",
parentThreadId: "saved-thread",
kind: "interacted",
agentPath: "/root/child",
});
appServer.says({ threadId: "child-thread", itemId: "child-reply", text: "Child replied." });
appServer.completesSubAgentActivity({
callId: "interact-child",
threadId: "child-thread",
parentThreadId: "saved-thread",
kind: "interacted",
agentPath: "/root/child",
});
appServer.completeTurn({ threadId: "saved-thread" });
await run;
const toolCalls = codex.manager
.getTimeline(codex.agentId)
.filter((item) => item.type === "tool_call");
expect(new Set(toolCalls.map((item) => item.callId))).toEqual(new Set(["spawn-child"]));
expect(toolCalls.at(-1)).toMatchObject({ callId: "spawn-child", status: "completed" });
expect(codex.manager.getProviderSubagent(codex.agentId, "child-thread")).toMatchObject({
toolCallId: "spawn-child",
status: "completed",
});
expect(
codex.manager
.fetchProviderSubagentTimeline(codex.agentId, "child-thread")
.rows.map((row) => row.item),
).toEqual([
expect.objectContaining({ type: "assistant_message", text: "Child started." }),
expect.objectContaining({ type: "assistant_message", text: "Child replied." }),
]);
} finally {
await codex.close();
}
});
test("closes Codex app-server when an interactive resume fails", async () => {
const appServer = createFakeCodexAppServer({
"thread/resume": () => Promise.reject(new Error("thread resume failed")),
@@ -4673,8 +4883,6 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
@@ -4727,7 +4935,7 @@ describe("Codex app-server provider", () => {
})),
};
await asInternals(session).loadPersistedHistory(session.client);
await Array.fromAsync(session.streamHistory());
expect(asInternals(session).codexUserMessageTurns().resolve("message-history")).toEqual({
index: 0,
@@ -4796,8 +5004,6 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
@@ -4958,8 +5164,6 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
@@ -5023,8 +5227,6 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
@@ -5200,8 +5402,6 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
@@ -5261,8 +5461,6 @@ describe("Codex app-server provider", () => {
}),
};
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
@@ -5564,7 +5762,10 @@ describe("Codex app-server provider", () => {
expect(session.currentThreadId).toBe("archived-thread-id");
expect(requests).toEqual([
{ method: "thread/loaded/list", params: {} },
{ method: "thread/resume", params: { threadId: "archived-thread-id" } },
{
method: "thread/resume",
params: { threadId: "archived-thread-id", excludeTurns: true },
},
]);
});
@@ -6399,8 +6600,6 @@ describe("Codex app-server provider", () => {
})),
};
await asInternals(session).loadPersistedHistory(session.client);
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
@@ -496,6 +496,11 @@ interface PersistedTimelineEntry {
providerTurnId?: string;
}
interface CodexThreadHistory {
timeline: PersistedTimelineEntry[];
providerSubagentEvents: AgentStreamEvent[];
}
interface PersistedSubAgentRoute {
childThreadId: string;
toolCall: ToolCallTimelineItem;
@@ -3464,10 +3469,12 @@ export class CodexAppServerAgentSession implements AgentSession {
private serviceTier: string | null = null;
private speedModels: CodexModel[] = [];
private planModeEnabled = false;
private historyPending = false;
private persistedHistory: PersistedTimelineEntry[] = [];
private loadingPersistedHistory = false;
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
// History read by rewind or an archived resume, held until streamHistory consumes it.
private stagedHistory: CodexThreadHistory | null = null;
// Collects provider subagent events emitted while a thread history read rebuilds routes.
private historySubagentEvents: AgentStreamEvent[] | null = null;
// A resumed thread's earlier turns and sub-agents are known only after reading its history.
private historyIndexed = true;
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private mcpElicitationPermissionIds = new Map<number, string>();
private pendingPermissionHandlers = new Map<string, CodexPendingPermissionHandler>();
@@ -3574,7 +3581,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
this.historyPending = true;
this.historyIndexed = false;
// Resuming can start a goal before the caller receives the session.
if (this.initialResumePurpose === "interactive") this.startupEvents = [];
}
@@ -3622,7 +3629,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private async establishConnection(): Promise<void> {
if (this.initialResumePurpose === "history") {
await this.readArchivedHistory();
this.stagedHistory = await this.readArchivedHistory();
this.connectionState = "history-ready";
return;
}
@@ -3655,7 +3662,6 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.currentThreadId) {
await this.ensureThreadLoaded();
await this.loadPersistedHistory(this.client);
await this.applyDefaultModelAndThinking();
}
@@ -3970,39 +3976,40 @@ export class CodexAppServerAgentSession implements AgentSession {
);
}
private async readArchivedHistory(): Promise<void> {
private async readArchivedHistory(): Promise<CodexThreadHistory> {
const child = await this.spawnAppServer();
const client = new CodexAppServerClient(child, this.logger, () => this.traceContext());
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
client.notify("initialized", {});
await this.loadPersistedHistory(client);
return await this.readThreadHistory(client);
} finally {
await client.dispose();
}
}
private async loadPersistedHistory(client: CodexAppServerClientLike | null): Promise<void> {
if (!client || !this.currentThreadId) return;
const threadId = this.currentThreadId;
const history = await loadCodexThreadHistoryTimeline({
threadId,
// Reads the thread and its sub-agent threads, rebuilding the turn index and
// sub-agent routes. The caller owns the returned history; nothing retains it.
private async readThreadHistory(
client: CodexAppServerClientLike | null,
): Promise<CodexThreadHistory> {
if (!client || !this.currentThreadId) {
return { timeline: [], providerSubagentEvents: [] };
}
const { timeline, subAgentRoutes } = await loadCodexThreadHistoryTimeline({
threadId: this.currentThreadId,
cwd: this.config.cwd ?? null,
requestThread: (threadIdToRead) => {
return readCodexThread(client, threadIdToRead);
},
requestThread: (threadIdToRead) => readCodexThread(client, threadIdToRead),
});
const { timeline, subAgentRoutes } = history;
this.subAgentCallsByCallId.clear();
this.subAgentCallIdByChildThreadId.clear();
this.pendingSubAgentNotificationsByThreadId.clear();
this.persistedProviderSubagentEvents = [];
this.loadingPersistedHistory = true;
const providerSubagentEvents: AgentStreamEvent[] = [];
this.historySubagentEvents = providerSubagentEvents;
try {
await this.loadPersistedSubAgentHistories(client, subAgentRoutes);
} finally {
this.loadingPersistedHistory = false;
this.historySubagentEvents = null;
}
this.resetCodexUserMessageTurns();
for (const entry of timeline) {
@@ -4013,8 +4020,15 @@ export class CodexAppServerAgentSession implements AgentSession {
this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId);
}
}
this.persistedHistory = timeline;
this.historyPending = timeline.length > 0;
this.historyIndexed = true;
return { timeline, providerSubagentEvents };
}
// Turns and rewind route by the thread's earlier turns and sub-agents. A resumed
// session whose projection Paseo already holds reads them here and drops the rest.
private async ensureHistoryIndexed(): Promise<void> {
if (this.historyIndexed) return;
await this.readThreadHistory(this.client);
}
private async loadPersistedSubAgentHistories(
@@ -4066,7 +4080,8 @@ export class CodexAppServerAgentSession implements AgentSession {
private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
const params: Record<string, unknown> = { threadId: this.currentThreadId };
// Paseo reads history only when it replays it; the resume response needs no turns.
const params: Record<string, unknown> = { threadId: this.currentThreadId, excludeTurns: true };
const developerInstructions = composeSystemPromptParts(
this.config.systemPrompt,
this.config.daemonAppendSystemPrompt,
@@ -4386,6 +4401,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.currentThreadId) {
await this.ensureThreadLoaded();
await this.ensureHistoryIndexed();
} else {
await this.ensureThread();
}
@@ -4559,21 +4575,12 @@ export class CodexAppServerAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (
(!this.historyPending || this.persistedHistory.length === 0) &&
this.persistedProviderSubagentEvents.length === 0
) {
return;
}
const history = this.persistedHistory;
const providerSubagents = this.persistedProviderSubagentEvents;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
for (const event of providerSubagents) {
const history = this.stagedHistory ?? (await this.readThreadHistory(this.client));
this.stagedHistory = null;
for (const event of history.providerSubagentEvents) {
yield event;
}
for (const entry of history) {
for (const entry of history.timeline) {
yield {
type: "timeline",
provider: CODEX_PROVIDER,
@@ -4956,6 +4963,7 @@ export class CodexAppServerAgentSession implements AgentSession {
} else {
await this.ensureThread();
}
await this.ensureHistoryIndexed();
await revertCodexConversation({
client: this.client,
@@ -4970,10 +4978,8 @@ export class CodexAppServerAgentSession implements AgentSession {
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
this.cachedRuntimeInfo = null;
this.persistedHistory = [];
this.historyPending = false;
await this.loadPersistedHistory(this.client);
this.reconcileAsyncQuestionsAfterRewind();
this.stagedHistory = await this.readThreadHistory(this.client);
this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline);
},
});
}
@@ -5211,6 +5217,8 @@ export class CodexAppServerAgentSession implements AgentSession {
await this.connect();
if (this.currentThreadId) {
await this.ensureThreadLoaded();
// A goal runs turns of its own.
await this.ensureHistoryIndexed();
} else {
await this.ensureThread();
}
@@ -5404,8 +5412,8 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private emitEvent(event: AgentStreamEvent): void {
if (this.loadingPersistedHistory && event.type === "provider_subagent") {
this.persistedProviderSubagentEvents.push(event);
if (this.historySubagentEvents && event.type === "provider_subagent") {
this.historySubagentEvents.push(event);
return;
}
this.notifySubscribers(event);
@@ -6583,9 +6591,9 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private reconcileAsyncQuestionsAfterRewind(): void {
private reconcileAsyncQuestionsAfterRewind(timeline: readonly PersistedTimelineEntry[]): void {
const retainedIds = new Set(
this.persistedHistory.flatMap(({ item }) =>
timeline.flatMap(({ item }) =>
item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [],
),
);
@@ -3,6 +3,7 @@ import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { writeFile, truncate, rm } from "node:fs/promises";
import { measureMainThreadWork } from "../test-utils/main-thread-work.js";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -20,24 +21,6 @@ import {
writePaseoConfigForEdit,
} from "./paseo-config-file.js";
async function measureConfigRead(directory: string) {
const fixture = fileURLToPath(
new URL("./test-fixtures/config-responsiveness.ts", import.meta.url),
);
const { stdout, stderr } = await promisify(execFile)(
process.execPath,
["--import", "tsx", fixture, directory],
{ timeout: 15_000 },
);
process.stderr.write(stderr);
return JSON.parse(stdout) as {
result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
blockingMs: number;
filesystemRequests: number;
yields: number;
};
}
describe("paseo config file substrate", () => {
let tempDir: string;
@@ -84,33 +67,6 @@ describe("paseo config file substrate", () => {
);
});
it("excludes unrelated test-runner CPU from the config read budget", async () => {
await writeFile(join(tempDir, "paseo.json"), JSON.stringify({ worktree: { setup: "npm ci" } }));
const unrelatedWork = new Promise<void>((resolve) => {
setImmediate(() => {
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
process,
"threadCpuUsage",
);
const start = threadCpuUsage();
let elapsed: NodeJS.CpuUsage;
do {
elapsed = threadCpuUsage(start);
} while (elapsed.user + elapsed.system < 60_000);
resolve();
});
});
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
await unrelatedWork;
expect(result).toEqual({
ok: true,
config: { worktree: { setup: "npm ci" } },
revision: await statPaseoConfigPath(tempDir),
});
expect(filesystemRequests).toBeGreaterThan(0);
expect(blockingMs).toBeLessThan(20);
});
it("keeps main-thread work below 20ms while admitting large configs", async () => {
const config = {
scripts: Object.fromEntries(
@@ -119,7 +75,10 @@ describe("paseo config file substrate", () => {
worktree: { setup: "npm ci" },
};
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
const { result, blockingMs, filesystemRequests, yields } = await measureConfigRead(tempDir);
const { result, blockingMs, filesystemRequests, yields } = await measureMainThreadWork(
"project config 7000 scripts main-thread work",
() => readPaseoConfigForEdit(tempDir),
);
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
expect(blockingMs).toBeLessThan(20);
expect(filesystemRequests).toBeGreaterThan(0);
@@ -131,7 +90,10 @@ describe("paseo config file substrate", () => {
join(tempDir, "paseo.json"),
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
);
const { result, blockingMs, filesystemRequests } = await measureConfigRead(tempDir);
const { result, blockingMs, filesystemRequests } = await measureMainThreadWork(
"project config 32MiB main-thread work",
() => readPaseoConfigForEdit(tempDir),
);
expect(blockingMs).toBeLessThan(20);
expect(filesystemRequests).toBeGreaterThan(0);
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
@@ -1,14 +0,0 @@
import assert from "node:assert/strict";
import { setImmediate as nextTurn } from "node:timers/promises";
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
import { readPaseoConfigForEdit } from "../paseo-config-file.js";
// Keep Vitest transforms, reporting, and unrelated callbacks outside the measured
// thread. Read the real file once, without warming the config snapshot or schema.
const directory = process.argv[2];
assert.ok(directory, "Config directory is required");
await nextTurn();
const measured = await measureMainThreadWork("project config main-thread work", () =>
readPaseoConfigForEdit(directory),
);
process.stdout.write(JSON.stringify(measured));