Compare commits

..
Author SHA1 Message Date
odoo-mvds 65ce4b590a fix(app): remove restored agent tabs from foreign workspaces (#7956)
A persisted explicit pin outlives archive recovery because its agent never joins the unrelated workspace directory. Drop positively foreign pins after hydration and let existing reconciliation remove only the misplaced view. Preserve unknown ownership and legitimate historical opens.

Adapts the repair proposed by kaicot in getpaseo/paseo#6901 to current main, with persisted-store and real-browser regression evidence. Refs getpaseo/paseo#6900.
2026-10-26 17:54:25 +00:00
35 changed files with 1192 additions and 1956 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

@@ -11,7 +11,10 @@ import {
expectWorkspaceTabVisible,
openWorkspaceWithAgents,
} from "../support/helpers/archive-tab";
import { expectAgentAbsentFromWorkspaceLayout } from "../support/helpers/workspace-layout";
import {
expectAgentAbsentFromWorkspaceLayout,
seedForeignPinnedAgentTab,
} from "../support/helpers/workspace-layout";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible } from "../support/helpers/composer";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
@@ -105,6 +108,83 @@ async function openWorkspaceTabUrl(page: Page, route: string): Promise<void> {
test.describe("Workspace navigation regression", () => {
test.describe.configure({ timeout: 240_000 });
test("removes a foreign pinned agent when restoring a saved workspace layout", async ({
page,
}, testInfo) => {
const serverId = getServerId();
const firstWorkspace = await seedWorkspace({
repoPrefix: "workspace-owner-",
title: "Original workspace",
});
const secondWorkspace = await seedWorkspace({
repoPrefix: "workspace-restored-",
title: "Restored workspace",
});
try {
const firstAgent = await createMockIdleAgent(firstWorkspace.client, {
cwd: firstWorkspace.repoPath,
workspaceId: firstWorkspace.workspaceId,
title: "Original workspace agent",
});
const secondAgent = await createMockIdleAgent(secondWorkspace.client, {
cwd: secondWorkspace.repoPath,
workspaceId: secondWorkspace.workspaceId,
title: "Restored workspace agent",
});
await gotoAppShell(page);
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
await seedForeignPinnedAgentTab(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await page.reload();
await waitForSidebarHydration(page);
await waitForWorkspaceTabsVisible(page);
await expectWorkspaceHeader(page, {
title: secondWorkspace.workspaceName,
subtitle: secondWorkspace.projectDisplayName,
});
await testInfo.attach("hydrated-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("hydrated-workspace.png") }),
contentType: "image/png",
});
await expectAgentAbsentFromWorkspaceLayout(page, {
serverId,
workspaceId: secondWorkspace.workspaceId,
agentId: firstAgent.id,
});
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
await testInfo.attach("restored-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("restored-workspace.png") }),
contentType: "image/png",
});
await switchWorkspaceViaSidebar({
page,
serverId,
workspaceId: firstWorkspace.workspaceId,
});
await waitForWorkspaceTabsVisible(page);
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
const agents = await firstWorkspace.client.fetchAgents();
expect(
agents.entries.find(({ agent }) => agent.id === firstAgent.id)?.agent.workspaceId,
).toBe(firstWorkspace.workspaceId);
expect(
agents.entries.find(({ agent }) => agent.id === secondAgent.id)?.agent.workspaceId,
).toBe(secondWorkspace.workspaceId);
await testInfo.attach("original-workspace", {
body: await page.screenshot({ path: testInfo.outputPath("original-workspace.png") }),
contentType: "image/png",
});
} finally {
await secondWorkspace.cleanup();
await firstWorkspace.cleanup();
}
});
test("keeps an offline notification on the agent route until the host connects", async ({
page,
}) => {
@@ -1,12 +1,47 @@
import { expect, type Page } from "@playwright/test";
import { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "../../../src/stores/workspace-layout-storage";
import { openTabInLayoutFocused } from "../../../src/stores/workspace-layout-actions";
import {
buildWorkspaceTabPersistenceKey,
type WorkspaceTab,
} from "../../../src/workspace-tabs/model";
import { getServerId } from "./server-id";
export async function seedForeignPinnedAgentTab(
page: Page,
input: { serverId: string; workspaceId: string; agentId: string },
): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
if (raw === null) throw new Error("Workspace layout was not persisted");
const stored = z
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number().optional() })
.parse(JSON.parse(raw));
const key = buildWorkspaceTabPersistenceKey(input);
const layout = key ? stored.state.layoutByWorkspace[key] : null;
if (!key || !layout) throw new Error(`No persisted layout for workspace ${input.workspaceId}`);
const opened = openTabInLayoutFocused({
layout,
target: { kind: "agent", agentId: input.agentId },
now: 1,
placement: { mode: "focused" },
explorerSidebarPaneId: stored.state.explorerSidebarPaneIdByWorkspace?.[key] ?? null,
});
if (!opened) throw new Error("Failed to seed foreign agent tab");
stored.state = WorkspaceLayoutPersistedStateSchema.parse({
...stored.state,
layoutByWorkspace: { ...stored.state.layoutByWorkspace, [key]: opened.layout },
pinnedAgentIdsByWorkspace: {
...stored.state.pinnedAgentIdsByWorkspace,
[key]: [...(stored.state.pinnedAgentIdsByWorkspace?.[key] ?? []), input.agentId],
},
});
await page.evaluate(
(value) => localStorage.setItem("workspace-layout-state", value),
JSON.stringify(stored),
);
}
/** Seed the reported hidden Explorer-only layout with 2,250 saved agent draft tabs. */
export async function seedCorruptedWorkspaceLayout(page: Page, workspaceId: string): Promise<void> {
const raw = await page.evaluate(() => localStorage.getItem("workspace-layout-state"));
@@ -271,6 +271,7 @@ export interface WorkspaceTabSnapshot {
terminalsHydrated: boolean;
activeAgentIds: Iterable<string>;
autoOpenAgentIds: Iterable<string>;
foreignAgentIds?: Iterable<string>;
knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>;
hasActivePendingTerminalCreate?: boolean;
@@ -2443,13 +2444,16 @@ export function reconcileWorkspaceTabs(
const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []);
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds);
// An explicit open owns the tab until the agent joins the active directory.
// From then on it follows the normal server archive lifecycle. Detail/cache
// hydration never decides whether the user's target is allowed to stay open.
const foreignAgentIds = normalizeStringSet(snapshot.foreignAgentIds ?? []);
// Explicit opens survive absent directory entries, but not known foreign ownership.
// Once an agent joins this workspace's active directory, normal archive rules apply.
const pinnedAgentIds = new Set(
[...(state.pinnedAgentIds ?? [])].filter(
(agentId) => !snapshot.agentsHydrated || !activeAgentIds.has(agentId),
),
[...(state.pinnedAgentIds ?? [])].filter((agentId) => {
if (!snapshot.agentsHydrated) {
return true;
}
return !activeAgentIds.has(agentId) && !foreignAgentIds.has(agentId);
}),
);
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
const knownTerminalIds = snapshot.knownTerminalIds
@@ -1,6 +1,7 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
buildWorkspaceTabSnapshot,
createWorkspaceAgentVisibilitySelector,
@@ -63,6 +64,118 @@ function makeAgent(input: {
const WORKSPACE_ID = "ws-1";
describe("workspace agent visibility", () => {
it("removes a restored pinned tab from an unrelated workspace once its owner is known", async () => {
const values = new Map<string, string>();
const storage = {
getItem: async (key: string) => values.get(key) ?? null,
setItem: async (key: string, value: string) => {
values.set(key, value);
},
removeItem: async (key: string) => {
values.delete(key);
},
};
const original = createWorkspaceLayoutStore(undefined, storage);
await original.persist.rehydrate();
const agent = makeAgent({ id: "restored-agent", cwd: "/project-a", workspaceId: "ws-a" });
for (const workspaceId of ["ws-a", "ws-b"]) {
original.getState().openTab({
workspaceKey: `srv:${workspaceId}`,
target: { kind: "agent", agentId: agent.id },
intent: "reveal",
pin: true,
});
}
const restored = createWorkspaceLayoutStore(undefined, storage);
await restored.persist.rehydrate();
const sessionAgents = new Map([[agent.id, agent]]);
const beforeHydration = buildWorkspaceTabSnapshot({
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId: "ws-b" }),
agentsHydrated: false,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
});
restored.getState().reconcileTabs("srv:ws-b", beforeHydration);
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toEqual(new Set([agent.id]));
for (const workspaceId of ["ws-a", "ws-b"]) {
restored.getState().reconcileTabs(
`srv:${workspaceId}`,
buildWorkspaceTabSnapshot({
agentVisibility: deriveWorkspaceAgentVisibility({ sessionAgents, workspaceId }),
agentsHydrated: true,
terminalsHydrated: true,
knownTerminalIds: [],
standaloneTerminalIds: [],
hasActivePendingTerminalCreate: false,
hasActivePendingDraftCreate: false,
}),
);
}
expect(
restored
.getState()
.getWorkspaceTabs("srv:ws-a")
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.target),
).toEqual([{ kind: "agent", agentId: agent.id }]);
expect(
restored
.getState()
.getWorkspaceTabs("srv:ws-b")
.filter((tab) => tab.target.kind === "agent"),
).toEqual([]);
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined();
});
it("uses directory ownership before historical details when identifying foreign agents", () => {
const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID });
const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" });
const unknown = makeAgent({ id: "unknown", cwd: "/repo" });
const result = deriveWorkspaceAgentVisibility({
workspaceId: WORKSPACE_ID,
sessionAgents: new Map([
[own.id, own],
[foreign.id, foreign],
[unknown.id, unknown],
]),
agentDetails: new Map([
[own.id, { ...own, workspaceId: "ws-2" }],
[foreign.id, { ...foreign, workspaceId: WORKSPACE_ID }],
[unknown.id, { ...unknown, workspaceId: "ws-2" }],
]),
});
expect(result.activeAgentIds).toEqual(new Set([own.id]));
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
});
it("recognizes foreign historical ownership without making archived or unknown agents foreign", () => {
const archived = makeAgent({
id: "archived",
cwd: "/repo",
workspaceId: WORKSPACE_ID,
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
});
const foreign = { ...archived, id: "foreign", workspaceId: "ws-2" };
const unknown = { ...archived, id: "unknown", workspaceId: undefined };
const result = deriveWorkspaceAgentVisibility({
workspaceId: WORKSPACE_ID,
sessionAgents: new Map(),
agentDetails: new Map([
[archived.id, archived],
[foreign.id, foreign],
[unknown.id, unknown],
]),
});
expect(result.activeAgentIds).toEqual(new Set());
expect(result.foreignAgentIds).toEqual(new Set([foreign.id]));
});
it("keeps subagents active while excluding them from auto-open", () => {
const parent = makeAgent({
id: "parent-agent",
@@ -86,6 +199,7 @@ describe("workspace agent visibility", () => {
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"]));
expect(result.foreignAgentIds).toEqual(new Set());
});
it("excludes archived subagents from active and auto-open", () => {
@@ -295,6 +409,7 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.foreignAgentIds).toEqual(new Set(["other-ws-agent"]));
});
it("excludes agents without a workspaceId", () => {
@@ -308,12 +423,14 @@ describe("workspace agent visibility", () => {
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.foreignAgentIds).toEqual(new Set<string>());
});
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
const agentVisibility = {
activeAgentIds: new Set(["active-agent"]),
autoOpenAgentIds: new Set(["root-agent"]),
foreignAgentIds: new Set(["foreign-agent"]),
};
expect(
@@ -331,6 +448,7 @@ describe("workspace agent visibility", () => {
terminalsHydrated: true,
activeAgentIds: agentVisibility.activeAgentIds,
autoOpenAgentIds: agentVisibility.autoOpenAgentIds,
foreignAgentIds: agentVisibility.foreignAgentIds,
knownTerminalIds: ["terminal-1", "script-terminal"],
standaloneTerminalIds: ["terminal-1"],
hasActivePendingTerminalCreate: false,
@@ -469,6 +587,24 @@ describe("workspace agent visibility", () => {
expect(after.activeAgentIds).toEqual(new Set([child.id]));
expect(after.autoOpenAgentIds).toEqual(new Set([child.id]));
});
it("recomputes when only a previously unknown agent's foreign ownership becomes known", () => {
seedSession(new Map([[parent.id, parent]]));
const select = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
const before = select(useSessionStore.getState());
useSessionStore
.getState()
.setAgentDetails(SERVER_ID, new Map([[otherWorkspace.id, otherWorkspace]]));
const after = select(useSessionStore.getState());
expect(after).not.toBe(before);
expect(after.activeAgentIds).toEqual(before.activeAgentIds);
expect(after.autoOpenAgentIds).toEqual(before.autoOpenAgentIds);
expect(after.foreignAgentIds).toEqual(new Set([otherWorkspace.id]));
});
});
describe("workspaceAgentVisibilityEqual", () => {
@@ -476,10 +612,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
};
const b = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set(["c"]),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
@@ -488,10 +626,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set(["b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
@@ -500,10 +640,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["a"]),
foreignAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set(["a", "b"]),
autoOpenAgentIds: new Set(["b"]),
foreignAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(false);
});
@@ -512,10 +654,12 @@ describe("workspace agent visibility", () => {
const a = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
@@ -6,10 +6,7 @@ import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
export interface WorkspaceAgentVisibility {
activeAgentIds: Set<string>;
autoOpenAgentIds: Set<string>;
}
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
foreignAgentIds: Set<string>;
}
export function deriveWorkspaceAgentVisibility(input: {
@@ -19,17 +16,23 @@ export function deriveWorkspaceAgentVisibility(input: {
}): WorkspaceAgentVisibility {
const { sessionAgents, agentDetails } = input;
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
if ((!sessionAgents && !agentDetails) || !workspaceId) {
if (!workspaceId) {
return {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
foreignAgentIds: new Set<string>(),
};
}
const activeAgentIds = new Set<string>();
const autoOpenAgentIds = new Set<string>();
const foreignAgentIds = new Set<string>();
for (const agent of sessionAgents?.values() ?? []) {
if (!agentBelongsToWorkspace(agent, workspaceId)) {
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (ownerId !== workspaceId) {
if (ownerId) {
foreignAgentIds.add(agent.id);
}
continue;
}
if (!agent.archivedAt) {
@@ -42,7 +45,16 @@ export function deriveWorkspaceAgentVisibility(input: {
}
}
}
return { activeAgentIds, autoOpenAgentIds };
for (const agent of agentDetails?.values() ?? []) {
if (sessionAgents?.has(agent.id)) {
continue;
}
const ownerId = normalizeWorkspaceOpaqueId(agent.workspaceId);
if (ownerId && ownerId !== workspaceId) {
foreignAgentIds.add(agent.id);
}
}
return { activeAgentIds, autoOpenAgentIds, foreignAgentIds };
}
interface AgentMapsSnapshot {
@@ -99,6 +111,7 @@ export function buildWorkspaceTabSnapshot(input: {
terminalsHydrated: input.terminalsHydrated,
activeAgentIds: input.agentVisibility.activeAgentIds,
autoOpenAgentIds: input.agentVisibility.autoOpenAgentIds,
foreignAgentIds: input.agentVisibility.foreignAgentIds,
knownTerminalIds: input.knownTerminalIds,
standaloneTerminalIds: input.standaloneTerminalIds,
hasActivePendingTerminalCreate: input.hasActivePendingTerminalCreate,
@@ -113,7 +126,8 @@ export function workspaceAgentVisibilityEqual(
return (
a === b ||
(setsEqual(a.activeAgentIds, b.activeAgentIds) &&
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds))
setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) &&
setsEqual(a.foreignAgentIds, b.foreignAgentIds))
);
}
@@ -1,13 +1,9 @@
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { performance } from "node:perf_hooks";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestPaseoDaemon } from "../../../test-utils/paseo-daemon.js";
import type { ClaudeRewindSdk } from "./rewind.js";
import { ClaudeAgentClient } from "./agent.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { withClaudeReplayHistory } from "./transcript-history.js";
@@ -36,10 +32,7 @@ describe("ClaudeAgentSession persisted history load", () => {
let configDir: string;
let records: LogRecord[];
async function resume(
queryFactory?: ClaudeQueryFactory,
rewindSdk?: ClaudeRewindSdk,
): Promise<AgentSession> {
async function resume(queryFactory?: ClaudeQueryFactory): Promise<AgentSession> {
const client = new ClaudeAgentClient({
logger: pino({ level: "info" }, { write: (line: string) => records.push(JSON.parse(line)) }),
queryFactory:
@@ -49,36 +42,10 @@ describe("ClaudeAgentSession persisted history load", () => {
}),
resolveBinary: async () => process.execPath,
resolveVersion: async () => "2.1.220",
rewindSdk,
});
return client.resumeSession({ provider: "claude", sessionId: SESSION_ID }, { cwd });
}
function replyFactory(text: string, messageId = "live-reply"): ClaudeQueryFactory {
return (() => {
const messages = [
{ type: "assistant", uuid: messageId, message: { content: text } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
return value ? { done: false, value } : { done: true, value: undefined };
},
close() {},
async return() {
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
}
function transcriptPath(): string {
return path.join(claudeProjectDirSync(cwd, { configDir }), `${SESSION_ID}.jsonl`);
}
@@ -98,286 +65,6 @@ describe("ClaudeAgentSession persisted history load", () => {
rmSync(tempRoot, { recursive: true, force: true });
});
test("serves unrelated health requests while resuming 32 MiB of ordered history", async () => {
const startedAt = performance.now();
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
const file = await open(transcriptPath(), "w");
const text = "x".repeat(16 * 1024);
let bytes = 0;
let count = 0;
try {
while (bytes < 32 * 1024 * 1024) {
const line =
JSON.stringify({ type: "assistant", message: { content: `${count}:${text}` } }) + "\n";
bytes += Buffer.byteLength(line);
await file.write(line);
count++;
}
} finally {
await file.close();
}
const daemon = await createTestPaseoDaemon();
let lastTick = performance.now();
let longestGap = 0;
const probe = setInterval(() => {
const now = performance.now();
longestGap = Math.max(longestGap, now - lastTick);
lastTick = now;
}, 2);
let loading = true;
let answeredDuringLoad = false;
const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => {
expect(response.status).toBe(200);
await response.text();
answeredDuringLoad = loading;
return undefined;
});
let session: AgentSession | undefined;
try {
session = await resume(replyFactory("live after large resume"));
let index = 0;
for await (const event of session.streamHistory()) {
expect(event).toEqual({
type: "timeline",
provider: "claude",
timestamp: undefined,
item: { type: "assistant_message", text: `${index}:${text}` },
});
index++;
}
loading = false;
await health;
await new Promise((resolve) => setTimeout(resolve, 5));
console.info(
JSON.stringify({
bytes,
count,
longestGap,
answeredDuringLoad,
elapsedMs: performance.now() - startedAt,
}),
);
expect(index).toBe(count);
expect(longestGap).toBeLessThan(50);
expect(answeredDuringLoad).toBe(true);
const live = await collectSessionTurnEvents(session, "follow up");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after large resume" },
]);
} finally {
clearInterval(probe);
await session?.close();
await daemon.close();
}
}, 60_000);
test("rebinds ordered history before returning from rewind and accepts a live turn", async () => {
const entries = [
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "first reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
{ type: "assistant", uuid: "a2", message: { content: "second reply" } },
];
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(transcriptPath(), entries.map((entry) => JSON.stringify(entry)).join("\n"));
const forkId = "rebound-history";
const forkFile = path.join(path.dirname(transcriptPath()), `${forkId}.jsonl`);
writeFileSync(
forkFile,
entries
.slice(0, 2)
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
const forks: unknown[] = [];
const late = {
type: "assistant",
uuid: "retired-reply",
message: { content: "retired query" },
};
let pendingRead: ((value: unknown) => void) | undefined;
let releaseRetirement!: () => void;
const retirementGate = new Promise<void>((resolve) => {
releaseRetirement = resolve;
});
let retirementStarted!: () => void;
const retiring = new Promise<void>((resolve) => {
retirementStarted = resolve;
});
let firstQuery = true;
let retired = false;
const queryFactory = (() => {
if (!firstQuery) return replyFactory("live after rebind")({} as never);
firstQuery = false;
const messages = [
{ type: "assistant", uuid: "before-rebind", message: { content: "before rebind" } },
{ type: "result", subtype: "success", total_cost_usd: 0 },
];
return {
async next() {
const value = messages.shift();
if (value) return { done: false, value };
if (retired) return { done: true, value: undefined };
return new Promise((resolve) => {
pendingRead = resolve;
});
},
close() {
retired = true;
pendingRead?.({ done: false, value: late });
},
async return() {
retirementStarted();
await retirementGate;
return { done: true, value: undefined };
},
async supportedCommands() {
return [];
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession(sessionId, options) {
forks.push({ sessionId, ...options });
return { sessionId: forkId };
},
});
try {
const original = await Array.fromAsync(session.streamHistory());
expect(original).toHaveLength(4);
await collectSessionTurnEvents(session, "before rewind");
const emitted: AgentStreamEvent[] = [];
const subscription = session.subscribe((event) => emitted.push(event));
let rewindDone = false;
const rewind = (async () => {
await session.revertConversation!({ messageId: "u2" });
rewindDone = true;
})();
await retiring;
let historyDone = false;
const reading = (async () => {
const history = await Array.fromAsync(session.streamHistory());
historyDone = true;
return history;
})();
const readingAgain = Array.fromAsync(session.streamHistory());
await Promise.resolve();
expect(rewindDone).toBe(false);
expect(historyDone).toBe(false);
releaseRetirement();
await rewind;
subscription();
expect(retired).toBe(true);
expect(
emitted.filter(
(event) => event.type === "timeline" && event.item.type === "assistant_message",
),
).toEqual([
{
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: `Claude switched to a new session: ${SESSION_ID} -> ${forkId}`,
},
},
]);
expect(forks).toEqual([
{ sessionId: SESSION_ID, upToMessageId: "a1", transcriptPath: transcriptPath() },
]);
expect(session.id).toBe(forkId);
const [history, secondRead] = await Promise.all([reading, readingAgain]);
expect(history).toEqual(original.slice(0, 2));
expect(secondRead).toEqual([]);
const live = await collectSessionTurnEvents(session, "continue the fork");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "assistant_message", messageId: "live-reply", text: "live after rebind" },
]);
} finally {
releaseRetirement();
await session.close();
}
});
test("keeps history and live turns available after query retirement fails during rebind", async () => {
mkdirSync(path.dirname(transcriptPath()), { recursive: true });
writeFileSync(
transcriptPath(),
[
{ type: "user", uuid: "u1", message: { content: "first" } },
{ type: "assistant", uuid: "a1", message: { content: "reply" } },
{ type: "user", uuid: "u2", message: { content: "second" } },
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
);
let firstQuery = true;
let finishRead: ((value: unknown) => void) | undefined;
const queryFactory = ((input) => {
const query = replyFactory(
firstQuery ? "before retirement" : "still available",
firstQuery ? "before-retirement" : "live-reply",
)(input);
if (!firstQuery) return query;
firstQuery = false;
let reads = 0;
return {
...query,
async next() {
if (++reads <= 2) return query.next();
return new Promise((resolve) => {
finishRead = resolve;
});
},
close() {
finishRead?.({ done: true, value: undefined });
throw new Error("retirement failed");
},
[Symbol.asyncIterator]() {
return this;
},
};
}) as ClaudeQueryFactory;
const session = await resume(queryFactory, {
async forkSession() {
return { sessionId: "failed-rebind" };
},
});
try {
await Array.fromAsync(session.streamHistory());
await collectSessionTurnEvents(session, "before rewind");
await expect(session.revertConversation!({ messageId: "u2" })).resolves.toBeUndefined();
expect(records).toContainEqual(
expect.objectContaining({
level: pino.levels.values.warn,
msg: "Failed to rebind Claude session",
sessionId: "failed-rebind",
err: expect.objectContaining({ message: "retirement failed" }),
}),
);
expect(await Array.fromAsync(session.streamHistory())).toEqual([]);
const live = await collectSessionTurnEvents(session, "after failed rebind");
expect(
live
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([{ type: "assistant_message", messageId: "live-reply", text: "still available" }]);
} finally {
await session.close();
}
});
test.each(["file_path", "files"])(
"replays %s without probing targets and preserves live existence checks",
async (field) => {
@@ -613,21 +300,21 @@ describe("ClaudeAgentSession persisted history load", () => {
);
});
test("loads readable history when the temporary directory is unavailable", async () => {
test("loads readable history when the temporary directory is unavailable", () => {
const file = transcriptPath();
const entry = { type: "assistant", message: { content: [{ type: "text", text: "saved" }] } };
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(entry));
const unavailable = path.join(tempRoot, "unavailable-tmp");
for (const variable of ["TMPDIR", "TEMP", "TMP"]) vi.stubEnv(variable, unavailable);
await withClaudeReplayHistory(file, async (history) => {
expect(await Array.fromAsync(history.parentEntries)).toEqual([entry]);
withClaudeReplayHistory(file, (history) => {
expect([...history.parentEntries]).toEqual([entry]);
});
});
test.each(["parent", "child", "workflow"])(
"preserves indexed %s history when its transcript disappears between passes",
async (target) => {
(target) => {
const parent = transcriptPath();
const sessionDirectory = parent.slice(0, -".jsonl".length);
const child = path.join(sessionDirectory, "subagents", "agent-child.jsonl");
@@ -647,16 +334,14 @@ describe("ClaudeAgentSession persisted history load", () => {
writeFileSync(parent, JSON.stringify(parentEntry));
writeFileSync(child, JSON.stringify(childEntry));
writeFileSync(workflow, JSON.stringify(childEntry));
await withClaudeReplayHistory(parent, async (history) => {
withClaudeReplayHistory(parent, (history) => {
rmSync({ parent, child, workflow }[target]);
// Ownership facts and visible payloads are separate passes over the same source.
for (let pass = 0; pass < 2; pass++) {
expect(await Array.fromAsync(history.parentEntries)).toEqual([parentEntry]);
expect([...history.parentEntries]).toEqual([parentEntry]);
expect(history.subagents).toHaveLength(1);
expect(await Array.fromAsync(history.subagents[0]!.entries)).toEqual([childEntry]);
expect(await Array.fromAsync(history.workflowEntriesByRunId.get("wf_test")!)).toEqual([
childEntry,
]);
expect([...history.subagents[0]!.entries]).toEqual([childEntry]);
expect([...history.workflowEntriesByRunId.get("wf_test")!]).toEqual([childEntry]);
}
});
},
@@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN =
/paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/;
interface ClaudeImageTestSession {
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
convertHistoryEntry(entry: unknown): Promise<AgentTimelineItem[]>;
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
convertHistoryEntry(entry: unknown): AgentTimelineItem[];
}
async function createSession(): Promise<ClaudeImageTestSession> {
@@ -157,7 +157,7 @@ describe("Claude tool_result image rendering", () => {
test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => {
const session = await createSession();
const events = await session.translateMessageToEvents(imageToolResultUserMessage());
const events = session.translateMessageToEvents(imageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -181,7 +181,7 @@ describe("Claude tool_result image rendering", () => {
test("replays the image as assistant markdown through history conversion", async () => {
const session = await createSession();
const items = await session.convertHistoryEntry(imageToolResultHistoryEntry());
const items = session.convertHistoryEntry(imageToolResultHistoryEntry());
const [imageMessage, ...extraImages] = imageMessages(items);
expect(extraImages).toEqual([]);
@@ -202,7 +202,7 @@ describe("Claude tool_result image rendering", () => {
test("keeps base64 out of an errored tool_result that carries an image", async () => {
const session = await createSession();
const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage());
const events = session.translateMessageToEvents(erroredImageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -226,7 +226,7 @@ describe("Claude tool_result image rendering", () => {
test("emits one image message per image block in a multi-image tool_result", async () => {
const session = await createSession();
const events = await session.translateMessageToEvents(multiImageToolResultUserMessage());
const events = session.translateMessageToEvents(multiImageToolResultUserMessage());
const timelineItems = events
.filter((event) => event.type === "timeline")
@@ -32,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
close(): Promise<void>;
}
@@ -53,7 +53,7 @@ afterEach(() => {
});
describe("convertClaudeHistoryEntry", () => {
test("maps user tool results to timeline items", async () => {
test("maps user tool results to timeline items", () => {
const toolUseId = "toolu_test";
const entry = {
type: "user",
@@ -79,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(stubTimeline);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual(stubTimeline);
expect(mapBlocks).toHaveBeenCalledTimes(1);
expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true);
});
test("replays persisted Claude tool results as completed tool calls", async () => {
test("replays persisted Claude tool results as completed tool calls", () => {
const entry = {
type: "user",
message: {
@@ -124,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => {
const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall;
expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
completedToolCall,
);
});
test("returns user messages when no tool blocks exist", async () => {
test("returns user messages when no tool blocks exist", () => {
const entry = {
type: "user",
message: {
@@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => {
},
};
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "user_message",
text: "Run npm test",
@@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("converts compact boundary metadata variants", async () => {
test("converts compact boundary metadata variants", () => {
const fixtures = [
{
entry: {
@@ -175,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
] as const;
for (const fixture of fixtures) {
expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
{
type: "compaction",
status: "completed",
@@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => {
}
});
test("skips synthetic user entries", async () => {
test("skips synthetic user entries", () => {
const entry = {
type: "user",
isSynthetic: true,
@@ -197,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips meta user entries from Claude skill loading", async () => {
test("skips meta user entries from Claude skill loading", () => {
const entry = {
type: "user",
isMeta: true,
@@ -219,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => {
};
const mapBlocks = vi.fn().mockReturnValue([]);
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
const result = convertClaudeHistoryEntry(entry, mapBlocks);
expect(result).toEqual([]);
expect(mapBlocks).not.toHaveBeenCalled();
});
test("skips interrupt placeholder transcript noise", async () => {
test("skips interrupt placeholder transcript noise", () => {
const interruptEntry = {
type: "user",
message: {
@@ -246,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => {
.fn()
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
});
test("skips <local-command-stdout> messages (model switch, /context, etc.)", async () => {
test("skips <local-command-stdout> messages (model switch, /context, etc.)", () => {
// Real entries from Claude Code JSONL history files
const modelSwitch = {
type: "user",
@@ -305,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => {
const mapBlocks = vi.fn().mockReturnValue([]);
expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
// Real user messages must NOT be filtered
const realMessage = {
type: "user",
message: { role: "user", content: "fix the bug in auth.ts" },
};
expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
{ type: "user_message", text: "fix the bug in auth.ts" },
]);
});
test("maps task notifications to synthetic tool calls", async () => {
test("maps task notifications to synthetic tool calls", () => {
const entry = {
type: "system",
subtype: "task_notification",
@@ -333,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => {
output_file: "/tmp/bg-fail-1.txt",
};
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "tool_call",
callId: "task_notification_task-note-system-1",
@@ -357,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("maps queue-operation task notifications to synthetic tool calls", async () => {
test("maps queue-operation task notifications to synthetic tool calls", () => {
const entry = {
type: "queue-operation",
operation: "enqueue",
@@ -372,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => {
].join("\n"),
};
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
{
type: "tool_call",
callId: "task_notification_task-note-queue-1",
@@ -396,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => {
]);
});
test("passes assistant content blocks through to the mapper", async () => {
test("passes assistant content blocks through to the mapper", () => {
const entry = {
type: "assistant",
message: {
@@ -414,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => {
];
const mapBlocks = vi.fn().mockReturnValue(mappedTimeline);
expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
expect(mapBlocks).toHaveBeenCalledWith(entry.message.content);
});
});
@@ -1208,7 +1208,7 @@ describe("ClaudeAgentSession features", () => {
input: Record<string, unknown>,
options: Record<string, unknown>,
): Promise<PermissionResult>;
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
};
try {
@@ -1248,7 +1248,7 @@ describe("ClaudeAgentSession features", () => {
),
).resolves.toMatchObject({ behavior: "deny", interrupt: undefined });
await internal.translateMessageToEvents({
internal.translateMessageToEvents({
type: "command_lifecycle",
command_uuid: steerUuid,
state: "started",
@@ -2395,7 +2395,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("emits canonical task snapshots from Claude TaskCreate results", async () => {
const session = await createSessionForTest();
try {
await session.translateMessageToEvents({
session.translateMessageToEvents({
type: "assistant",
message: {
content: [
@@ -2409,7 +2409,7 @@ describe("ClaudeAgentSession context window usage", () => {
},
} as unknown as SDKMessage);
const events = await session.translateMessageToEvents({
const events = session.translateMessageToEvents({
type: "user",
message: {
content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }],
@@ -3611,9 +3611,9 @@ describe("ClaudeAgentSession context window usage", () => {
]);
try {
const compactEvents = await (
session as unknown as TestClaudeSession
).translateMessageToEvents(createCompactBoundary());
const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
createCompactBoundary(),
);
expect(compactEvents).toContainEqual(
expect.objectContaining({
type: "usage_updated",
@@ -3799,7 +3799,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
const session = await createSessionForTest();
const events = await session.translateMessageToEvents({
const events = session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Unknown command: /foo-doesnt-exist",
@@ -3834,7 +3834,7 @@ describe("ClaudeAgentSession context window usage", () => {
test("result.result is not duplicated when the model produced output during the turn", async () => {
const session = await createSessionForTest();
const events = await session.translateMessageToEvents({
const events = session.translateMessageToEvents({
type: "result",
subtype: "success",
result: "Here is the answer.",
@@ -1,4 +1,3 @@
import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
@@ -83,7 +82,7 @@ import {
type ClaudeRewindSdk,
} from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPath } from "./project-dir.js";
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
import {
isProviderImageMarkdown,
@@ -1635,7 +1634,7 @@ export class ClaudeAgentClient implements AgentClient {
cwd: merged.cwd,
};
const claudeConfig = this.assertConfig(mergedConfig);
const session = new ClaudeAgentSession(claudeConfig, {
return new ClaudeAgentSession(claudeConfig, {
defaults: this.defaults,
runtimeSettings: this.runtimeSettings,
handle,
@@ -1646,8 +1645,6 @@ export class ClaudeAgentClient implements AgentClient {
resolveBinary: this.resolveBinary,
rewindSdk: this.rewindSdk,
});
await session.ensureHistoryReady();
return session;
}
async getCatalogCacheKey(_options: FetchCatalogOptions): Promise<string> {
@@ -2332,9 +2329,6 @@ class ClaudeAgentSession implements AgentSession {
{ type: "provider_subagent" }
>[] = [];
private historyPending = false;
// Readiness never rejects; history/rebind failures are logged at their owning boundary.
private historyReady: Promise<void> | null = null;
private historySessionId: string | null = null;
private replayingHistory = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
@@ -2388,7 +2382,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.claudeSessionId = handle.sessionId;
this.persistence = handle;
this.historySessionId = handle.sessionId;
this.loadPersistedHistory(handle.sessionId);
} else {
this.claudeSessionId = null;
this.persistence = null;
@@ -2468,7 +2462,6 @@ class ClaudeAgentSession implements AgentSession {
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<{ turnId: string }> {
await this.ensureHistoryReady();
if (this.closed) {
throw new Error("Claude session is closed");
}
@@ -2546,11 +2539,7 @@ class ClaudeAgentSession implements AgentSession {
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch(
(error: unknown) => {
this.logger.warn({ err: error }, "Failed to emit submitted Claude message");
},
);
this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
}
}, 0);
} catch (error) {
@@ -2643,7 +2632,6 @@ class ClaudeAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
await this.ensureHistoryReady();
if (
!this.historyPending ||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
@@ -2655,9 +2643,7 @@ class ClaudeAgentSession implements AgentSession {
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
let replayed = 0;
for (const entry of history) {
if (++replayed % 32 === 0) await setImmediate();
yield {
type: "timeline",
item: entry.item,
@@ -2975,7 +2961,6 @@ class ClaudeAgentSession implements AgentSession {
"provider.claude.session_close.start",
);
this.closed = true;
await this.ensureHistoryReady();
this.rejectAllPendingPermissions(new Error("Claude session closed"));
this.cancelCurrentTurn?.();
this.subscribers.clear();
@@ -3016,7 +3001,7 @@ class ClaudeAgentSession implements AgentSession {
// (see `claude --help`), so the SDK's persistSession=false is silently dropped
// in stream-json mode. Sweep the transcript ourselves so ephemeral runs
// (metadata generator, branch-name generator) don't show up as resumable.
const historyPath = await this.resolveHistoryPath(this.claudeSessionId);
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
if (historyPath) {
try {
await promises.rm(historyPath, { force: true });
@@ -3060,7 +3045,6 @@ class ClaudeAgentSession implements AgentSession {
}
async revertConversation(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const target = this.resolveConversationRewindTarget(input.messageId);
if (target.kind === "fresh-session") {
this.startFreshConversationSession();
@@ -3070,15 +3054,16 @@ class ClaudeAgentSession implements AgentSession {
await revertClaudeConversation({
sdk: this.rewindSdk,
sessionId: currentSessionId,
transcriptPath: currentSessionId ? await this.resolveHistoryPath(currentSessionId) : null,
transcriptPath: currentSessionId ? this.resolveHistoryPath(currentSessionId) : null,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
setSessionId: (sessionId) => this.rebindConversationSession(sessionId),
setSessionId: (sessionId) => {
this.rebindConversationSession(sessionId);
},
});
}
async revertFiles(input: { messageId: string }): Promise<void> {
await this.ensureHistoryReady();
const messageId = await this.resolveClaudeMessageId(input.messageId);
await revertClaudeFiles({
query: await this.ensureQuery(),
@@ -3253,51 +3238,38 @@ class ClaudeAgentSession implements AgentSession {
return candidates;
}
private rebindConversationSession(sessionId: string): Promise<void> {
const previousReady = this.ensureHistoryReady();
this.historyReady = (async () => {
await previousReady;
await this.retireQuery();
if (this.closed) return;
const oldSessionId = this.claudeSessionId;
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.historySessionId = sessionId;
await this.loadPersistedHistory(sessionId);
if (this.closed) return;
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
{
type: "timeline",
provider: "claude",
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
},
{
type: "thread_started",
provider: "claude",
sessionId,
},
]);
}
})().catch((error) => {
this.logger.warn({ err: error, sessionId }, "Failed to rebind Claude session");
});
return this.historyReady;
private rebindConversationSession(sessionId: string): void {
const oldSessionId = this.claudeSessionId;
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.loadPersistedHistory(sessionId);
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
{
type: "timeline",
provider: "claude",
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
},
{
type: "thread_started",
provider: "claude",
sessionId,
},
]);
}
}
private startFreshConversationSession(): void {
this.historySessionId = null;
this.historyReady = Promise.resolve();
const sessionId = randomUUID();
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = sessionId;
@@ -3418,43 +3390,7 @@ class ClaudeAgentSession implements AgentSession {
return { kind: "fresh-session" };
}
private async retireQuery(): Promise<void> {
const oldQuery = this.query;
if (!oldQuery) return;
const oldInput = this.input;
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
await this.ensureHistoryReady();
// Controls use the running turn's query. Pending launch settings take effect
// after that turn finishes, when its active query handle has been cleared.
if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) {
@@ -3462,7 +3398,37 @@ class ClaudeAgentSession implements AgentSession {
}
if (this.queryRestartNeeded && this.query) {
await this.retireQuery();
const oldQuery = this.query;
const oldInput = this.input;
// Null out query/input BEFORE awaiting the old iterator's return so the
// old pump sees this.query !== activeQuery and skips failActiveTurns.
this.query = null;
this.input = null;
this.queryPumpPromise = null;
this.queryRestartNeeded = false;
// Ending the input retires the process on purpose. Detach first so its
// exit is not reported as a crash.
const retiredChild = this.childProcess;
this.childProcess = null;
if (retiredChild) this.failRunningRuntimeTasks();
oldInput?.end();
oldQuery.close?.();
try {
await oldQuery.return?.();
} catch {
/* ignore */
}
// Tree-kill the old process tree now that the SDK has cleaned up.
// If we skip this, MCP children of the previous claude process can
// survive as orphans when the session spawns a replacement query.
if (retiredChild) {
await terminateWithTreeKill(retiredChild, {
gracefulTimeoutMs: 2_000,
forceTimeoutMs: 2_000,
}).catch(() => {
/* process may already be dead */
});
}
}
// Preserve claudeSessionId across query recreation so buildOptions() passes
@@ -4129,14 +4095,12 @@ class ClaudeAgentSession implements AgentSession {
);
};
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
if (!this.ownsQuery(activeQuery)) return true;
this.processingSdkMessages++;
try {
this.runtimeSafety.observe(message);
logRawMessage(message);
consecutiveInterruptAbortRecoveries = 0;
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
if (!this.ownsQuery(activeQuery)) return true;
await this.routeSdkMessageFromPump(message);
return false;
} finally {
@@ -4257,10 +4221,6 @@ class ClaudeAgentSession implements AgentSession {
return isClaudeTurnInit(message) && !this.activeForegroundTurnId;
}
private ownsQuery(query: Query): boolean {
return !this.closed && this.query === query;
}
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
if (this.consumeSessionStateChange(message)) {
return;
@@ -4335,17 +4295,10 @@ class ClaudeAgentSession implements AgentSession {
messageIdHint: string | null,
turnId: string | null,
): Promise<AgentStreamEvent[]> {
const query = this.query;
const messageEvents = await this.translateMessageToEvents(message, {
const messageEvents = this.translateMessageToEvents(message, {
suppressAssistantText: true,
suppressReasoning: true,
});
if (
this.closed ||
this.query !== query ||
(this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId
)
return [];
const assistantTimelineEvents = readClaudeParentToolUseId(message)
? []
: this.timelineAssembler
@@ -4490,13 +4443,13 @@ class ClaudeAgentSession implements AgentSession {
return withdrewAll;
}
private async translateMessageToEvents(
private translateMessageToEvents(
message: SDKMessage,
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): Promise<AgentStreamEvent[]> {
): AgentStreamEvent[] {
const parentToolUseId = readClaudeParentToolUseId(message);
if (parentToolUseId) {
return this.translateSidechainFrameToEvents(message, parentToolUseId);
@@ -4544,11 +4497,11 @@ class ClaudeAgentSession implements AgentSession {
this.appendSystemMessageEvents(message, events);
break;
case "user":
await this.appendUserMessageEvents(message, events);
this.appendUserMessageEvents(message, events);
this.appendSidechainResultEvents(message, events);
break;
case "assistant": {
const timelineItems = await this.mapBlocksToTimeline(message.message.content, {
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
@@ -4559,7 +4512,7 @@ class ClaudeAgentSession implements AgentSession {
break;
}
case "stream_event":
await this.appendStreamEventEvents(message, events, options);
this.appendStreamEventEvents(message, events, options);
break;
case "result":
this.appendResultEvents(message, events);
@@ -4661,14 +4614,13 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async emitSubmittedUserMessage(
private emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>,
turnId: string,
clientMessageId?: string,
): Promise<void> {
): void {
const events: AgentStreamEvent[] = [];
await this.appendUserMessageEvents(message, events);
if (this.activeForegroundTurnId !== turnId) return;
this.appendUserMessageEvents(message, events);
if (events.length === 0) {
return;
}
@@ -4798,10 +4750,10 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async appendUserMessageEvents(
private appendUserMessageEvents(
message: Extract<SDKMessage, { type: "user" }>,
events: AgentStreamEvent[],
): Promise<void> {
): void {
if (isSyntheticUserEntry(message)) {
return;
}
@@ -4840,7 +4792,7 @@ class ClaudeAgentSession implements AgentSession {
return;
}
if (Array.isArray(content)) {
await this.appendUserContentArrayEvents(content, messageId, events);
this.appendUserContentArrayEvents(content, messageId, events);
}
}
@@ -4866,12 +4818,12 @@ class ClaudeAgentSession implements AgentSession {
events.push({ type: "timeline", item, provider: "claude" });
}
private async appendUserContentArrayEvents(
private appendUserContentArrayEvents(
content: ReadonlyArray<unknown>,
messageId: string | undefined,
events: AgentStreamEvent[],
): Promise<void> {
const timelineItems = await this.mapBlocksToTimeline(content, {
): void {
const timelineItems = this.mapBlocksToTimeline(content, {
textMessageType: "user_message",
});
for (const item of timelineItems) {
@@ -4887,16 +4839,16 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async appendStreamEventEvents(
private appendStreamEventEvents(
message: Extract<SDKMessage, { type: "stream_event" }>,
events: AgentStreamEvent[],
options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined,
): Promise<void> {
): void {
const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event);
if (usageUpdatedEvent) {
events.push(usageUpdatedEvent);
}
const timelineItems = await this.mapPartialEvent(message.event, {
const timelineItems = this.mapPartialEvent(message.event, {
suppressAssistantText: options?.suppressAssistantText ?? false,
suppressReasoning: options?.suppressReasoning ?? false,
});
@@ -5320,31 +5272,22 @@ class ClaudeAgentSession implements AgentSession {
}
}
ensureHistoryReady(): Promise<void> {
if (!this.historyReady) {
this.historyReady = this.historySessionId
? this.loadPersistedHistory(this.historySessionId)
: Promise.resolve();
}
return this.historyReady;
}
private async loadPersistedHistory(sessionId: string): Promise<void> {
private loadPersistedHistory(sessionId: string): void {
let historyPath: string | null = null;
this.replayingHistory = true;
try {
this.taskState.reset();
historyPath = await this.resolveHistoryPath(sessionId);
if (!historyPath || !(await transcriptExists(historyPath))) {
historyPath = this.resolveHistoryPath(sessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
this.logger.info(
{ sessionId, cwd: this.config.cwd, historyPath },
"No Claude transcript to load history from",
);
return;
}
await withClaudeReplayHistory(historyPath, async (history) => {
const replay = await this.ingestPersistedSidechains(history);
await this.ingestPersistedHistory(history.parentEntries, replay);
withClaudeReplayHistory(historyPath, (history) => {
const replay = this.ingestPersistedSidechains(history);
this.ingestPersistedHistory(history.parentEntries, replay);
});
} catch (error) {
this.logger.warn(
@@ -5356,13 +5299,13 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
private ingestPersistedHistory(
entries: Iterable<ClaudeHistoryEntry>,
replay: ClaudeReplayOwnership,
): Promise<void> {
): void {
const timeline: PersistedTimelineEntry[] = [];
for await (const entry of entries) {
await this.ingestPersistedHistoryEntry(entry, timeline, replay);
for (const entry of entries) {
this.ingestPersistedHistoryEntry(entry, timeline, replay);
}
if (timeline.length > 0) {
@@ -5371,33 +5314,27 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async ingestPersistedSidechains(
history: ClaudeReplayHistory,
): Promise<ClaudeReplayOwnership> {
const parentFacts = await readClaudeReplayParentFacts(history.parentEntries);
private ingestPersistedSidechains(history: ClaudeReplayHistory): ClaudeReplayOwnership {
const parentFacts = readClaudeReplayParentFacts(history.parentEntries);
// Replay produces the same observations the live task protocol produces, then folds them
// with the same function, so identity and status are derived once for both paths.
const subagents = [];
for (const subagent of history.subagents) {
subagents.push({
const subagentReplay = observeReplaySubagents({
subagents: history.subagents.map((subagent) => ({
...subagent,
parentFacts: await readClaudeReplayParentFacts(subagent.entries),
});
}
const subagentReplay = await observeReplaySubagents({
subagents,
parentFacts: readClaudeReplayParentFacts(subagent.entries),
})),
parent: parentFacts,
convertEntry: (entry) => this.convertHistoryEntry(entry),
});
const observations = [
...subagentReplay.observations,
...(await observeReplayWorkflows({
...observeReplayWorkflows({
workflows: history.workflows,
parentEntries: history.parentEntries,
entriesByRunId: history.workflowEntriesByRunId,
convertEntry: (entry) => this.convertHistoryEntry(entry),
})),
}),
];
const restoredProviderSubagentIds = new Set(
observations
@@ -5422,11 +5359,11 @@ class ClaudeAgentSession implements AgentSession {
return replay;
}
private async ingestPersistedHistoryEntry(
private ingestPersistedHistoryEntry(
entry: ClaudeHistoryEntry,
timeline: PersistedTimelineEntry[],
replay: ClaudeReplayOwnership,
): Promise<void> {
): void {
if (entry.isSidechain) {
return;
}
@@ -5440,7 +5377,7 @@ class ClaudeAgentSession implements AgentSession {
? replay.toolOwners.get(notificationToolUseId)
: undefined;
if (notificationOwner) {
for (const item of await this.convertHistoryEntry(entry)) {
for (const item of this.convertHistoryEntry(entry)) {
this.persistedProviderSubagentEvents.push({
type: "provider_subagent",
provider: "claude",
@@ -5457,7 +5394,7 @@ class ClaudeAgentSession implements AgentSession {
const taskSnapshot = this.taskState.observe(entry);
const items = [
...(taskSnapshot ? [taskSnapshot] : []),
...(await this.convertHistoryEntry(entry)).map((item) =>
...this.convertHistoryEntry(entry).map((item) =>
labelReplayedSubagentCard(item, replay.subagentToolCalls),
),
];
@@ -5484,20 +5421,20 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async resolveHistoryPath(sessionId: string): Promise<string | null> {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
return claudeTranscriptPath({
return claudeTranscriptPathSync({
cwd,
sessionId,
configDir: claudeConfigDir(this.harnessEnvironment),
});
}
private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise<AgentTimelineItem[]> {
return (
await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content))
).map(limitAgentTimelineItemContent);
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map(
limitAgentTimelineItemContent,
);
}
// Maps Claude content blocks into AgentTimelineItems.
@@ -5512,14 +5449,14 @@ class ClaudeAgentSession implements AgentSession {
//
// NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function
// for user entries. Both paths must produce equivalent user_message items.
private async mapBlocksToTimeline(
private mapBlocksToTimeline(
content: string | ReadonlyArray<unknown>,
options?: {
textMessageType?: "assistant_message" | "user_message";
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): Promise<AgentTimelineItem[]> {
): AgentTimelineItem[] {
const textMessageType = options?.textMessageType ?? "assistant_message";
const suppressText =
textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false);
@@ -5546,7 +5483,7 @@ class ClaudeAgentSession implements AgentSession {
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
continue;
}
await this.mapBlockToTimeline(block, {
this.mapBlockToTimeline(block, {
items,
userTextParts,
textMessageType,
@@ -5591,7 +5528,7 @@ class ClaudeAgentSession implements AgentSession {
}
}
private async mapBlockToTimeline(
private mapBlockToTimeline(
block: ClaudeContentChunk,
context: {
items: AgentTimelineItem[];
@@ -5600,7 +5537,7 @@ class ClaudeAgentSession implements AgentSession {
suppressText: boolean;
suppressReasoning: boolean;
},
): Promise<void> {
): void {
switch (block.type) {
case "text":
case "text_delta":
@@ -5624,7 +5561,7 @@ class ClaudeAgentSession implements AgentSession {
case "code_execution_tool_result":
case "bash_code_execution_tool_result":
case "text_editor_code_execution_tool_result":
await this.handleToolResult(block, context.items);
this.handleToolResult(block, context.items);
break;
default:
break;
@@ -5670,10 +5607,7 @@ class ClaudeAgentSession implements AgentSession {
);
}
private async handleToolResult(
block: ClaudeContentChunk,
items: AgentTimelineItem[],
): Promise<void> {
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
const entry =
typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined;
@@ -5706,7 +5640,7 @@ class ClaudeAgentSession implements AgentSession {
this.rememberSettledSubagentToolCall(settledToolCall, entry);
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) {
@@ -5930,13 +5864,13 @@ class ClaudeAgentSession implements AgentSession {
return false;
}
private async mapPartialEvent(
private mapPartialEvent(
event: SDKPartialAssistantMessage["event"],
options?: {
suppressAssistantText?: boolean;
suppressReasoning?: boolean;
},
): Promise<AgentTimelineItem[]> {
): AgentTimelineItem[] {
if (this.updatePartialEventToolState(event)) {
return [];
}
@@ -5944,14 +5878,14 @@ class ClaudeAgentSession implements AgentSession {
switch (event.type) {
case "content_block_start":
return isClaudeContentChunk(event.content_block)
? await this.mapBlocksToTimeline([event.content_block], {
? this.mapBlocksToTimeline([event.content_block], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
: [];
case "content_block_delta":
return isClaudeContentChunk(event.delta)
? await this.mapBlocksToTimeline([event.delta], {
? this.mapBlocksToTimeline([event.delta], {
suppressAssistantText: options?.suppressAssistantText,
suppressReasoning: options?.suppressReasoning,
})
@@ -6257,13 +6191,13 @@ function labelReplayedSubagentCard(
* source consumes. The agentId scrape is kept only as a fallback for sessions recorded before
* Claude Code wrote the meta sidecar.
*/
async function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry> | AsyncIterable<ClaudeHistoryEntry>,
): Promise<ClaudeReplayParentFacts> {
function readClaudeReplayParentFacts(
entries: Iterable<ClaudeHistoryEntry>,
): ClaudeReplayParentFacts {
const toolCalls = new Map<string, { title?: string; description?: string }>();
const linksByAgentId = new Map<string, { toolCallId: string; failed: boolean }>();
const outcomes = new Map<string, { failed: boolean }>();
for await (const entry of entries) {
for (const entry of entries) {
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -6314,14 +6248,12 @@ interface ClaudeHistoryEntry {
[key: string]: unknown;
}
async function mapAssistantHistoryBlocksWithMessageId(
function mapAssistantHistoryBlocksWithMessageId(
entry: ClaudeHistoryEntry,
content: string | ClaudeContentChunk[],
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
const items = await mapBlocks(content);
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
): AgentTimelineItem[] {
const items = mapBlocks(content);
const assistantMessageId =
typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null;
if (!assistantMessageId) {
@@ -6389,12 +6321,10 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
}
export async function convertClaudeHistoryEntry(
export function convertClaudeHistoryEntry(
entry: ClaudeHistoryEntry,
mapBlocks: (
content: string | ClaudeContentChunk[],
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
): Promise<AgentTimelineItem[]> {
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
): AgentTimelineItem[] {
const preamble = convertClaudeHistoryEntryPreamble(entry);
if ("shortCircuit" in preamble) {
return preamble.shortCircuit;
@@ -6433,7 +6363,7 @@ export async function convertClaudeHistoryEntry(
}
if (hasToolBlock && normalizedBlocks) {
const mapped = await mapBlocks(normalizedBlocks);
const mapped = mapBlocks(normalizedBlocks);
if (entry.type === "user") {
// tool_result handling (handleToolResult) emits image markdown as an assistant_message
// alongside the tool_call. User-entry text blocks also map to assistant_message in this path
@@ -6447,7 +6377,7 @@ export async function convertClaudeHistoryEntry(
}
if (entry.type === "assistant" && contentValue) {
return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
}
return timeline;
@@ -6799,7 +6729,7 @@ async function readClaudeRenamedSessionTitle(input: {
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8");
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
@@ -6899,13 +6829,3 @@ function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle |
state: record.state as ClaudeCommandLifecycle["state"],
};
}
async function transcriptExists(file: string): Promise<boolean> {
try {
await fs.promises.access(file);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
}
@@ -1,5 +1,5 @@
import { realpathSync } from "node:fs";
import { realpath, access, readdir } from "node:fs/promises";
import { existsSync, readdirSync, realpathSync } from "node:fs";
import { realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
@@ -35,21 +35,21 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
// transcript exists anywhere.
export async function claudeTranscriptPath(input: {
export function claudeTranscriptPathSync(input: {
cwd: string;
sessionId: string;
configDir?: string;
}): Promise<string> {
}): string {
const options = { configDir: input.configDir };
const fileName = `${input.sessionId}.jsonl`;
const expected = join(await claudeProjectDir(input.cwd, options), fileName);
if (await exists(expected)) {
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
if (existsSync(expected)) {
return expected;
}
const projectsRoot = join(resolveConfigDir(options), "projects");
let projectDirs: string[];
try {
projectDirs = await readdir(projectsRoot);
projectDirs = readdirSync(projectsRoot);
} catch (error) {
if (isMissingDirectoryError(error)) {
return expected;
@@ -58,23 +58,13 @@ export async function claudeTranscriptPath(input: {
}
for (const projectDir of projectDirs) {
const candidate = join(projectsRoot, projectDir, fileName);
if (await exists(candidate)) {
if (existsSync(candidate)) {
return candidate;
}
}
return expected;
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch (error) {
if (isMissingDirectoryError(error)) return false;
throw error;
}
}
function isMissingDirectoryError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === "ENOENT" || code === "ENOTDIR";
@@ -76,7 +76,7 @@ export async function revertClaudeConversation(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void | Promise<void>;
setSessionId: (sessionId: string) => void;
}): Promise<void> {
if (!input.sessionId || !input.transcriptPath) {
throw new Error("Claude session is not ready for rewind");
@@ -86,7 +86,7 @@ export async function revertClaudeConversation(input: {
upToMessageId: messageId,
transcriptPath: input.transcriptPath,
});
await input.setSessionId(fork.sessionId);
input.setSessionId(fork.sessionId);
}
export async function revertClaudeFiles(input: {
@@ -108,7 +108,7 @@ export async function revertClaudeConversationAndFiles(input: {
transcriptPath: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void | Promise<void>;
setSessionId: (sessionId: string) => void;
}): Promise<void> {
await revertClaudeFiles({
query: input.query,
@@ -1,9 +1,3 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "../../provider-image-output.js";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { describe, expect, it } from "vitest";
@@ -51,7 +45,7 @@ function applyToStore(observations: SubagentObservation[]): ProviderSubagentDesc
}
describe("parseClaudeSubagentMeta", () => {
it("reads the real sidecar shape", async () => {
it("reads the real sidecar shape", () => {
expect(
parseClaudeSubagentMeta(
'{"agentType":"general-purpose","description":"Reply banana","toolUseId":"toolu_013i","spawnDepth":1}',
@@ -74,7 +68,7 @@ describe("parseClaudeSubagentMeta", () => {
expect(parseClaudeSubagentMeta(contents)).toBeNull();
});
it("keeps the good fields when one field has the wrong type", async () => {
it("keeps the good fields when one field has the wrong type", () => {
expect(parseClaudeSubagentMeta('{"agentType":"Explore","toolUseId":42}')).toEqual({
agentType: "Explore",
description: undefined,
@@ -85,14 +79,12 @@ describe("parseClaudeSubagentMeta", () => {
});
describe("observeReplaySubagents", () => {
it("links a subagent to its Task call through meta.toolUseId", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("links a subagent to its Task call through meta.toolUseId", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations[0]).toMatchObject({
kind: "declared",
@@ -103,33 +95,31 @@ describe("observeReplaySubagents", () => {
});
});
it("replays descendants beneath the sidechain that declared their Task", async () => {
it("replays descendants beneath the sidechain that declared their Task", () => {
const nestedToolUseId = "toolu_012rzYnFZA";
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
},
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
},
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
},
{
agentId: "a6acb4b898",
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations).toContainEqual(
expect.objectContaining({
@@ -140,143 +130,89 @@ describe("observeReplaySubagents", () => {
);
});
it("keeps a subagent recorded before spawnDepth existed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("keeps a subagent recorded before spawnDepth existed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
});
it("drops a meta-linked subagent when the parent never declared its Task", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
it("drops a meta-linked subagent when the parent never declared its Task", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, agentType: "Explore", description: "Find the code" },
entries: [],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
expect(observations).toEqual([]);
});
it("falls back to the scraped link when there is no meta file", async () => {
it("falls back to the scraped link when there is no meta file", () => {
const parent: ClaudeReplayParentFacts = {
toolCalls: new Map([[TOOL_USE_ID, { title: "Explore" }]]),
linksByAgentId: new Map([[AGENT_ID, { toolCallId: TOOL_USE_ID, failed: false }]]),
outcomesByToolCallId: new Map(),
};
const [declared, ...rest] = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
})
).observations;
const [declared, ...rest] = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
}).observations;
expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" });
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
});
it("drops an unlinkable subagent", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
it("drops an unlinkable subagent", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
expect(observations).toEqual([]);
});
it("drops a terminal subagent the parent has no Task call for", async () => {
it("drops a terminal subagent the parent has no Task call for", () => {
// A legacy grandchild: its toolUseId names a Task call made inside a sibling's transcript, so
// no tool_result for it can ever reach the parent. Left running, it never stops.
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations,
);
expect(descriptor).toBeNull();
});
it("drops a terminal skill-spawned subagent that has no link at all", async () => {
it("drops a terminal skill-spawned subagent that has no link at all", () => {
// A /code-review run: no Task tool_use exists to name it, so its sidecar carries only
// agentType and its id appears nowhere in the parent transcript.
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", async () => {
const descriptor = applyToStore(
(
await 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, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
})
).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", async () => {
const observations = (
await observeReplaySubagents({
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
@@ -285,81 +221,111 @@ describe("observeReplaySubagents", () => {
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: null },
message: { stop_reason: "end_turn" },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
})
).observations;
}).observations,
);
expect(descriptor).toBeNull();
});
it("reports a scraped link's failure as failed", () => {
const descriptor = applyToStore(
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, failed: true }]]),
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
}).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
});
it("drops a non-terminal linkless subagent", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { agentType: "general-purpose" },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: null },
},
],
},
],
parent: emptyParent(),
convertEntry: () => [],
}).observations;
expect(observations).toEqual([]);
});
it("finishes a terminal Task child when the parent has not recorded its outcome", async () => {
it("finishes a terminal Task child when the parent has not recorded its outcome", () => {
const descriptor = applyToStore(
(
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations,
observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-31T03:41:00.000Z",
message: { stop_reason: "end_turn" },
},
],
},
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations,
);
expect(descriptor).toMatchObject({ status: "completed" });
});
it("leaves a subagent with no recorded outcome running", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
})
).observations;
it("leaves a subagent with no recorded outcome running", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}).observations;
expect(observations.some((o) => o.kind === "status")).toBe(false);
});
it("reports a failed outcome as failed", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
})
).observations;
it("reports a failed outcome as failed", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
}).observations;
expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" });
});
it("carries entry timestamps onto timeline observations", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
})
).observations;
it("carries entry timestamps onto timeline observations", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", timestamp: "2026-07-26T06:27:47.034Z" }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
}).observations;
expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({
kind: "timeline",
@@ -370,20 +336,18 @@ describe("observeReplaySubagents", () => {
});
describe("replay runtime", () => {
it("reads the model and effort off the subagent's own transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("reads the model and effort off the subagent's own transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations.find((o) => o.kind === "subtitle")).toEqual({
kind: "subtitle",
@@ -392,55 +356,49 @@ describe("replay runtime", () => {
});
});
it("takes the last observed values when the model changes mid-transcript", async () => {
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("takes the last observed values when the model changes mid-transcript", () => {
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{ type: "assistant", effort: "high", message: { model: "claude-opus-5" } },
{ type: "assistant", effort: "low", message: { model: "claude-sonnet-5" } },
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · Sonnet 5 · Low",
});
});
it("reports nothing when the transcript never names a model or effort", async () => {
const observations = (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
it("reports nothing when the transcript never names a model or effort", () => {
const observations = observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations.some((o) => o.kind === "subtitle")).toBe(false);
});
it("passes through a model the manifest does not know", async () => {
it("passes through a model the manifest does not know", () => {
// Claude Code is an Anthropic-compatible client; subagents legitimately report other models.
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { model: "glm-5.1" } }],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · glm-5.1",
});
@@ -448,24 +406,20 @@ describe("replay runtime", () => {
});
describe("replay usage", () => {
async function subtitleOf(
entries: ClaudeReplayEntry[],
): Promise<SubagentObservation | undefined> {
return (
await observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations.find((observation) => observation.kind === "subtitle");
function subtitleOf(entries: ClaudeReplayEntry[]): SubagentObservation | undefined {
return observeReplaySubagents({
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations.find((observation) => observation.kind === "subtitle");
}
it("derives the counters Claude Code itself reports, from a real transcript's shape", async () => {
it("derives the counters Claude Code itself reports, from a real transcript's shape", () => {
// Shape and numbers taken from an on-disk subagent transcript. Claude Code finalizes a
// subagent from the LAST assistant entry's usage, so the earlier, smaller turn must not win
// and must not be added in.
expect(
await subtitleOf([
subtitleOf([
{
type: "assistant",
timestamp: "2026-07-23T23:18:43.023Z",
@@ -508,9 +462,9 @@ describe("replay usage", () => {
});
});
it("formats tokens for a child that only answered", async () => {
it("formats tokens for a child that only answered", () => {
expect(
await subtitleOf([
subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -525,12 +479,12 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 16.4k tokens" });
});
it("emits nothing for a transcript with no entries", async () => {
expect(await subtitleOf([])).toBeUndefined();
it("emits nothing for a transcript with no entries", () => {
expect(subtitleOf([])).toBeUndefined();
});
it("keeps duration out of the compact subtitle", async () => {
const observation = await subtitleOf([
it("keeps duration out of the compact subtitle", () => {
const observation = subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -540,8 +494,8 @@ describe("replay usage", () => {
expect(observation).toMatchObject({ subtitle: "general-purpose · 10 tokens" });
});
it("omits totalTokens when no entry carries a usage block", async () => {
const observation = await subtitleOf([
it("omits totalTokens when no entry carries a usage block", () => {
const observation = subtitleOf([
{ type: "assistant", timestamp: "2026-07-27T16:42:14.698Z", message: { content: [] } },
{
type: "assistant",
@@ -552,9 +506,9 @@ describe("replay usage", () => {
expect(observation).toBeUndefined();
});
it("keeps the last usage-bearing entry when the final assistant entry has none", async () => {
it("keeps the last usage-bearing entry when the final assistant entry has none", () => {
expect(
await subtitleOf([
subtitleOf([
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
@@ -565,36 +519,34 @@ describe("replay usage", () => {
).toMatchObject({ subtitle: "general-purpose · 1k tokens" });
});
it("only reports counters the transcript actually shows", async () => {
it("only reports counters the transcript actually shows", () => {
// A user-only transcript says nothing about cost: no assistant turn, so no tool count either.
const observation = await subtitleOf([
const observation = subtitleOf([
{ type: "user", timestamp: "2026-07-27T16:42:14.698Z", message: { content: "prompt" } },
{ type: "user", timestamp: "2026-07-27T16:42:20.000Z", message: { content: "more" } },
]);
expect(observation).toBeUndefined();
});
it("lands the complete provider-owned subtitle on the descriptor", async () => {
it("lands the complete provider-owned subtitle on the descriptor", () => {
const store = new ProviderSubagentStore();
const observations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
const observations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [
{
type: "assistant",
timestamp: "2026-07-27T16:42:14.698Z",
message: { content: [], usage: { input_tokens: 7 } },
},
],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
}
@@ -607,7 +559,7 @@ describe("live and replay agree", () => {
// The property the design exists to guarantee: one session, two sources, one descriptor.
// This is inexpressible against the inference implementation, where the paths share no
// vocabulary and each derives identity and status its own way.
it("produces identical descriptor state from the wire and from disk", async () => {
it("produces identical descriptor state from the wire and from disk", () => {
const live = new ClaudeTaskProtocolSource();
const liveObservations = [
...live.observe({
@@ -627,23 +579,21 @@ describe("live and replay agree", () => {
} as unknown as SDKMessage),
];
const replayObservations = (
await observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
},
entries: [],
const replayObservations = observeReplaySubagents({
subagents: [
{
agentId: AGENT_ID,
meta: {
toolUseId: TOOL_USE_ID,
agentType: "general-purpose",
description: "Summarize hover and unistyles docs",
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
})
).observations;
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).observations;
const fromWire = applyToStore(liveObservations);
const fromDisk = applyToStore(replayObservations);
@@ -666,34 +616,3 @@ describe("live and replay agree", () => {
});
});
});
it("awaits image materialization before publishing replay observations", async () => {
const bytes = Buffer.from("async replay image fixture");
const convertEntry = async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: bytes.toString("base64"), mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
return item ? [item] : [];
};
const { observations } = await observeReplaySubagents({
parent: parentWithTaskCall(),
subagents: [
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID },
entries: [{ type: "assistant", message: { content: [] } }],
},
],
convertEntry,
});
const image = observations.find(
(observation) =>
observation.kind === "timeline" && observation.item.type === "assistant_message",
);
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
throw new Error("Expected replay image");
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
expect(source).toBeTruthy();
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
});
@@ -130,7 +130,7 @@ function readUsage(entry: ClaudeReplayEntry): ClaudeSubagentUsage | null {
export interface ClaudeReplaySubagentInput {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
entries: Iterable<ClaudeReplayEntry>;
/** Task declarations made by this subagent, used to attach its direct children. */
parentFacts?: ClaudeReplayParentFacts;
}
@@ -250,13 +250,13 @@ function observeSubtitle(
};
}
async function observeSubagent(
function observeSubagent(
subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
toolOwners: Map<string, string>,
parentSubagentId?: string,
): Promise<SubagentObservation[]> {
): SubagentObservation[] {
const link = resolveParentLink(subagent, parent);
if (!link) return [];
const toolCall = parent.toolCalls.get(link.toolCallId);
@@ -269,7 +269,7 @@ async function observeSubagent(
let childTerminalStatus: ProviderSubagentStatus | null = null;
// Consume each source once for presentation and timeline; keep no raw transcript records.
for await (const entry of subagent.entries) {
for (const entry of subagent.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
if (!seenEntry) firstTimestamp = timestamp;
seenEntry = true;
@@ -280,7 +280,7 @@ async function observeSubagent(
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
}
recordReplayToolOwners(toolOwners, entry, link.id);
for (const item of await convertEntry(entry)) {
for (const item of convertEntry(entry)) {
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
}
}
@@ -320,11 +320,11 @@ function recordReplayToolOwners(
}
}
export async function observeReplaySubagents(input: {
export function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } {
const observations: SubagentObservation[] = [];
const toolOwners = new Map<string, string>();
const unresolved = [...input.subagents].sort(
@@ -346,7 +346,7 @@ export async function observeReplaySubagents(input: {
const { ownerId, parent, link } = resolved;
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
for (const observation of await observeSubagent(
for (const observation of observeSubagent(
subagent,
parent,
input.convertEntry,
@@ -1,9 +1,3 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import {
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "../../provider-image-output.js";
import { describe, expect, it } from "vitest";
import { ProviderSubagentStore } from "../../../provider-subagents/store.js";
@@ -41,7 +35,7 @@ function parentEntries(
}
describe("Claude workflow replay", () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", async () => {
it("parses the persisted run summary observed from Claude Code 2.1.220", () => {
expect(
parseClaudeWorkflowRun(
JSON.stringify({
@@ -68,8 +62,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds the same generic completed descriptor through the parent Workflow call", async () => {
const observations = await observeReplayWorkflows({
it("rebuilds the same generic completed descriptor through the parent Workflow call", () => {
const observations = observeReplayWorkflows({
workflows: [
{
runId: RUN_ID,
@@ -97,8 +91,8 @@ describe("Claude workflow replay", () => {
});
});
it("rebuilds a workflow when Claude persists its result as structured text blocks", async () => {
const observations = await observeReplayWorkflows({
it("rebuilds a workflow when Claude persists its result as structured text blocks", () => {
const observations = observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Structured result workflow", status: "completed" }],
parentEntries: parentEntries([
{
@@ -120,7 +114,7 @@ describe("Claude workflow replay", () => {
});
});
it("replays the workflow result without exposing its script or child prompt", async () => {
it("replays the workflow result without exposing its script or child prompt", () => {
const workflow = parseClaudeWorkflowRun(
JSON.stringify({
runId: RUN_ID,
@@ -134,7 +128,7 @@ describe("Claude workflow replay", () => {
expect(workflow).not.toBeNull();
if (!workflow) throw new Error("expected workflow");
const observations = await observeReplayWorkflows({
const observations = observeReplayWorkflows({
workflows: [workflow],
parentEntries: parentEntries(),
});
@@ -151,9 +145,9 @@ describe("Claude workflow replay", () => {
expect(JSON.stringify(observations)).not.toContain("SECRET CHILD PROMPT");
});
it("does not duplicate a result already present in the child transcript", async () => {
it("does not duplicate a result already present in the child transcript", () => {
const report = "What Paseo is\nA local-first coding-agent environment.";
const observations = await observeReplayWorkflows({
const observations = observeReplayWorkflows({
workflows: [
{ runId: RUN_ID, summary: "Inspect Paseo", status: "completed", result: { report } },
],
@@ -172,8 +166,8 @@ describe("Claude workflow replay", () => {
).toHaveLength(1);
});
it("fails a nonterminal persisted run because its former runtime cannot update it", async () => {
const observations = await observeReplayWorkflows({
it("fails a nonterminal persisted run because its former runtime cannot update it", () => {
const observations = observeReplayWorkflows({
workflows: [{ runId: RUN_ID, summary: "Interrupted workflow", status: "running" }],
parentEntries: parentEntries(),
});
@@ -185,9 +179,9 @@ describe("Claude workflow replay", () => {
});
});
it("drops a run that no parent Workflow tool call declared", async () => {
it("drops a run that no parent Workflow tool call declared", () => {
expect(
await observeReplayWorkflows({
observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: [],
}),
@@ -201,29 +195,3 @@ describe("Claude workflow replay", () => {
},
);
});
it("awaits image materialization before publishing replay observations", async () => {
const bytes = Buffer.from("async replay image fixture");
const convertEntry = async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: bytes.toString("base64"), mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
return item ? [item] : [];
};
const observations = await observeReplayWorkflows({
workflows: [{ runId: RUN_ID, status: "completed" }],
parentEntries: parentEntries(),
entriesByRunId: new Map([[RUN_ID, [{ type: "assistant", message: { content: [] } }]]]),
convertEntry,
});
const image = observations.find(
(observation) =>
observation.kind === "timeline" && observation.item.type === "assistant_message",
);
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
throw new Error("Expected replay image");
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
expect(source).toBeTruthy();
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
});
@@ -45,17 +45,14 @@ export function parseClaudeWorkflowRun(contents: string): ClaudeWorkflowRun | nu
* provider runtime was recreated, so replay terminalizes it as failed instead of resurrecting a
* row that can never receive another update.
*/
export async function observeReplayWorkflows(input: {
export function observeReplayWorkflows(input: {
workflows: readonly ClaudeWorkflowRun[];
parentEntries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>;
parentEntries: Iterable<ClaudeWorkflowParentEntry>;
/** Repeatable entries in chronological order, supplied by the transcript reader. */
entriesByRunId?: ReadonlyMap<
string,
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
}): Promise<SubagentObservation[]> {
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
entriesByRunId?: ReadonlyMap<string, Iterable<ClaudeReplayEntry>>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): SubagentObservation[] {
const toolCallIdByRunId = readWorkflowLinks(input.parentEntries);
const observations: SubagentObservation[] = [];
for (const workflow of input.workflows) {
@@ -82,7 +79,7 @@ export async function observeReplayWorkflows(input: {
});
}
for (const observation of await observeWorkflowTimeline({
for (const observation of observeWorkflowTimeline({
id,
result: workflow.result,
entries: input.entriesByRunId?.get(workflow.runId) ?? [],
@@ -118,19 +115,19 @@ export async function observeReplayWorkflows(input: {
return observations;
}
async function observeWorkflowTimeline(input: {
function observeWorkflowTimeline(input: {
id: string;
result: unknown;
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
entries: Iterable<ClaudeReplayEntry>;
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
finishedAt?: string;
}): Promise<SubagentObservation[]> {
}): SubagentObservation[] {
const observations: SubagentObservation[] = [];
const replayedAssistantText = new Set<string>();
for await (const entry of input.entries) {
for (const entry of input.entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = (await input.convertEntry?.(entry)) ?? [];
const items = input.convertEntry?.(entry) ?? [];
for (const item of items) {
if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim());
observations.push({
@@ -153,13 +150,11 @@ async function observeWorkflowTimeline(input: {
return observations;
}
async function readWorkflowLinks(
entries: Iterable<ClaudeWorkflowParentEntry> | AsyncIterable<ClaudeWorkflowParentEntry>,
): Promise<Map<string, string>> {
function readWorkflowLinks(entries: Iterable<ClaudeWorkflowParentEntry>): Map<string, string> {
const workflowToolCallIds = new Set<string>();
const toolCallIdByRunId = new Map<string, string>();
for await (const entry of entries) {
for (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -170,7 +165,7 @@ async function readWorkflowLinks(
}
}
for await (const entry of entries) {
for (const entry of entries) {
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
@@ -221,11 +221,9 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
/** Normalize notification identity for one transcript traversal. Queue bookkeeping and delivery
* are separate records of the same occurrence; each source counts repeated identical bodies
* independently so the second event is not merged with the first. */
export async function* assignTaskNotificationReplayIds<T>(
entries: AsyncIterable<T>,
): AsyncGenerator<T> {
export function* assignTaskNotificationReplayIds<T>(entries: Iterable<T>): Generator<T> {
const occurrences = new Map<string, Map<string, number>>();
for await (const entry of entries) {
for (const entry of entries) {
const record = TaskNotificationHistoryRecordSchema.safeParse(entry);
if (!record.success) {
yield entry;
@@ -1,6 +1,5 @@
import { assignTaskNotificationReplayIds } from "./task-notification-tool-call.js";
import fs, { type FileHandle } from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import fs from "node:fs";
import path from "node:path";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import {
@@ -25,11 +24,11 @@ interface RecordLocation {
}
/** Chunk in bytes so UTF-8 and multi-chunk records are decoded once, at a line boundary. */
async function* locatedRecords(
function* locatedRecords(
file: string,
fd: FileHandle,
): AsyncGenerator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = await fd.stat();
fd: number,
): Generator<{ entry: TranscriptEntry; location: RecordLocation }> {
const stat = fs.fstatSync(fd);
// Opening a directory succeeds on some platforms; it must never look like empty history.
if (stat.isDirectory()) {
throw Object.assign(new Error(`EISDIR: illegal operation on a directory, read '${file}'`), {
@@ -44,7 +43,7 @@ async function* locatedRecords(
let length = 0;
while (remaining > 0) {
const buffer = Buffer.allocUnsafe(Math.min(64 * 1024, remaining));
const { bytesRead: count } = await fd.read(buffer, 0, buffer.length, null);
const count = fs.readSync(fd, buffer, 0, buffer.length, null);
if (count === 0) break;
remaining -= count;
let start = 0;
@@ -65,8 +64,6 @@ async function* locatedRecords(
if (entry) yield { entry, location };
start = end + 1;
}
// Bound parsing work as well as IO; microtasks alone do not service timers or sockets.
await setImmediate();
if (start < count) {
pieces.push(buffer.subarray(start, count));
length += count - start;
@@ -88,25 +85,20 @@ async function* locatedRecords(
/** Indexed sources parse only their own records using descriptors owned by this replay. */
function indexedRecords(
locations: readonly RecordLocation[],
descriptors: ReadonlyMap<string, FileHandle>,
): AsyncIterable<TranscriptEntry> {
descriptors: ReadonlyMap<string, number>,
): Iterable<TranscriptEntry> {
return {
async *[Symbol.asyncIterator]() {
*[Symbol.iterator]() {
yield* assignTaskNotificationReplayIds(readEntries());
},
};
async function* readEntries(): AsyncGenerator<TranscriptEntry> {
function* readEntries(): Generator<TranscriptEntry> {
for (const location of locations) {
const fd = descriptors.get(location.file)!;
const buffer = Buffer.allocUnsafe(location.length);
let read = 0;
while (read < buffer.length) {
const { bytesRead: count } = await fd.read(
buffer,
read,
buffer.length - read,
location.offset + read,
);
const count = fs.readSync(fd, buffer, read, buffer.length - read, location.offset + read);
if (count === 0)
throw new Error(`Claude transcript was truncated during replay: ${location.file}`);
read += count;
@@ -135,9 +127,9 @@ function parseRecord(line: string): TranscriptEntry | null {
}
}
async function readOptionalFile(file: string): Promise<string | null> {
function readOptionalFile(file: string): string | null {
try {
return await fs.readFile(file, "utf8");
return fs.readFileSync(file, "utf8");
} catch {
return null;
}
@@ -146,40 +138,31 @@ async function readOptionalFile(file: string): Promise<string | null> {
/** Discover sources without retaining transcript contents. Unrelated sidechains stay excluded
* by the replay ownership resolver; workflow children retain their separate run ownership. */
export interface ClaudeReplayHistory {
parentEntries: AsyncIterable<TranscriptEntry>;
parentEntries: Iterable<TranscriptEntry>;
subagents: {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: AsyncIterable<TranscriptEntry>;
entries: Iterable<TranscriptEntry>;
}[];
workflows: ClaudeWorkflowRun[];
workflowEntriesByRunId: Map<string, AsyncIterable<TranscriptEntry>>;
workflowEntriesByRunId: Map<string, Iterable<TranscriptEntry>>;
}
/** Own descriptors through every pass so unlink or rename cannot invalidate discovered history.
* Sources must be consumed before the callback returns. No transcript copies are needed. */
export async function withClaudeReplayHistory<T>(
export function withClaudeReplayHistory<T>(
historyPath: string,
consume: (history: ClaudeReplayHistory) => Promise<T>,
): Promise<T> {
const descriptors = new Map<string, FileHandle>();
consume: (history: ClaudeReplayHistory) => T,
): T {
const descriptors = new Map<string, number>();
try {
return await consume(await indexHistory(historyPath, descriptors));
return consume(indexHistory(historyPath, descriptors));
} finally {
await closeReplaySources(descriptors.values());
for (const fd of descriptors.values()) fs.closeSync(fd);
}
}
async function closeReplaySources(descriptors: Iterable<FileHandle>): Promise<void> {
const results = await Promise.allSettled([...descriptors].map((fd) => fd.close()));
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
}
async function indexHistory(
historyPath: string,
descriptors: Map<string, FileHandle>,
): Promise<ClaudeReplayHistory> {
function indexHistory(historyPath: string, descriptors: Map<string, number>): ClaudeReplayHistory {
const sessionDirectory = path.join(
path.dirname(historyPath),
path.basename(historyPath, ".jsonl"),
@@ -191,7 +174,8 @@ async function indexHistory(
const directories = [sidechainDirectory];
while (directories.length) {
const directory = directories.pop()!;
for (const entry of await optionalDirectory(directory)) {
if (!fs.existsSync(directory)) continue;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const file = path.join(directory, entry.name);
if (entry.isDirectory()) {
directories.push(file);
@@ -210,23 +194,23 @@ async function indexHistory(
}
const agentId = /^agent-(.+)\.meta\.json$/.exec(entry.name)?.[1];
if (!agentId) continue;
const contents = await readOptionalFile(file);
const contents = readOptionalFile(file);
const meta = contents === null ? null : parseClaudeSubagentMeta(contents);
if (meta) metaByAgentId.set(agentId, meta);
}
}
const parentLocations: RecordLocation[] = [];
const locationsByAgentId = new Map<string, RecordLocation[]>();
async function scan(
function scan(
file: string,
visit: (entry: TranscriptEntry, location: RecordLocation) => void,
): Promise<void> {
const fd = await fs.open(file, "r");
): void {
const fd = fs.openSync(file, "r");
descriptors.set(file, fd);
for await (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
for (const { entry, location } of locatedRecords(file, fd)) visit(entry, location);
}
for (const file of sidechainFiles) {
await scan(file, (entry, location) => {
scan(file, (entry, location) => {
if (file === historyPath && entry.isSidechain !== true) parentLocations.push(location);
if (entry.isSidechain !== true || typeof entry.agentId !== "string") return;
const locations = locationsByAgentId.get(entry.agentId) ?? [];
@@ -234,11 +218,11 @@ async function indexHistory(
locationsByAgentId.set(entry.agentId, locations);
});
}
const workflowEntriesByRunId = new Map<string, AsyncIterable<TranscriptEntry>>();
const workflowEntriesByRunId = new Map<string, Iterable<TranscriptEntry>>();
for (const [runId, files] of workflowFiles) {
const locations: RecordLocation[] = [];
for (const file of files)
await scan(file, (entry, location) => {
scan(file, (entry, location) => {
if (entry.type !== "user" || isToolResult(entry)) locations.push(location);
});
locations.sort((a, b) => {
@@ -256,28 +240,21 @@ async function indexHistory(
meta: metaByAgentId.get(agentId) ?? null,
entries: indexedRecords(locations, descriptors),
})),
workflows: await readWorkflows(sessionDirectory),
workflows: readWorkflows(sessionDirectory),
workflowEntriesByRunId,
};
}
async function readWorkflows(sessionDirectory: string): Promise<ClaudeWorkflowRun[]> {
function readWorkflows(sessionDirectory: string): ClaudeWorkflowRun[] {
const workflows: ClaudeWorkflowRun[] = [];
const workflowDirectory = path.join(sessionDirectory, "workflows");
for (const entry of await optionalDirectory(workflowDirectory)) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = await readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
if (fs.existsSync(workflowDirectory)) {
for (const entry of fs.readdirSync(workflowDirectory, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
const contents = readOptionalFile(path.join(workflowDirectory, entry.name));
const workflow = contents === null ? null : parseClaudeWorkflowRun(contents);
if (workflow) workflows.push(workflow);
}
}
return workflows;
}
async function optionalDirectory(directory: string) {
try {
return await fs.readdir(directory, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
File diff suppressed because it is too large Load Diff
@@ -1939,12 +1939,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null
};
}
async function mapCodexThreadImageItem(
function mapCodexThreadImageItem(
normalizedType: string,
normalizedItem: Record<string, unknown>,
): Promise<AgentTimelineItem | null> {
): AgentTimelineItem | null {
if (normalizedType === "imageView") {
return await renderProviderImageOutputAsAssistantMarkdown({
return renderProviderImageOutputAsAssistantMarkdown({
path: firstStringField(normalizedItem, ["path"]),
});
}
@@ -1960,7 +1960,7 @@ async function mapCodexThreadImageItem(
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
const result = codexImageOutputFromResult(normalizedItem.result);
return await renderProviderImageOutputAsAssistantMarkdown(
return renderProviderImageOutputAsAssistantMarkdown(
{
path: savedPath ?? result?.path ?? null,
url: result?.url ?? null,
@@ -1982,10 +1982,10 @@ function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem
};
}
export async function threadItemToTimeline(
export function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): Promise<AgentTimelineItem | null> {
): AgentTimelineItem | null {
const itemRecord = toObjectRecord(item);
if (!itemRecord) return null;
const includeUserMessage = options?.includeUserMessage ?? true;
@@ -1999,7 +1999,7 @@ export async function threadItemToTimeline(
: itemRecord;
if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
return await mapCodexThreadImageItem(normalizedType, normalizedItem);
return mapCodexThreadImageItem(normalizedType, normalizedItem);
}
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
@@ -2024,7 +2024,7 @@ export async function threadItemToTimeline(
}
}
async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimelineItem[]> {
function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
const itemRecord = toObjectRecord(item);
if (!itemRecord) {
return [];
@@ -2037,25 +2037,24 @@ async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimeli
}
const { images } = splitCodexMcpToolResultImages(itemRecord.result);
const items: AgentTimelineItem[] = [];
for (const image of images) {
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
});
if (imageItem) items.push(imageItem);
}
return items;
return images
.map((image) =>
renderProviderImageOutputAsAssistantMarkdown(image, {
materialize: materializeProviderImage,
}),
)
.filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null);
}
async function threadItemToTimelineEntries(
function threadItemToTimelineEntries(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): Promise<AgentTimelineItem[]> {
const timelineItem = await threadItemToTimeline(item, options);
): AgentTimelineItem[] {
const timelineItem = threadItemToTimeline(item, options);
if (!timelineItem) {
return [];
}
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
return [timelineItem, ...mcpToolResultImagesToTimeline(item)];
}
const CodexThreadReadResponseSchema = z
@@ -2104,7 +2103,7 @@ async function loadCodexThreadHistoryTimeline(params: {
historicalSubAgentActivity.agentThreadId,
);
if (existingIndex !== undefined) {
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
updateHistoricalSubAgentActivity(
timeline,
existingIndex,
@@ -2117,7 +2116,7 @@ async function loadCodexThreadHistoryTimeline(params: {
continue;
}
}
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
const timestamp =
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
const settledTimelineItem =
@@ -3291,12 +3290,10 @@ export async function codexAppServerTurnInputFromPrompt(
}
if (block.type === "image") {
try {
const filePath = (
await materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
})
).path;
const filePath = materializeProviderImage({
data: block.data,
mimeType: block.mimeType,
}).path;
output.push({ type: "localImage", path: filePath });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -4373,7 +4370,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
try {
await this.pendingNotifications;
await this.connect();
if (!this.client) {
throw new Error("Codex client not initialized");
@@ -5441,21 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
return `codex-turn-${this.nextTurnOrdinal++}`;
}
private pendingNotifications: Promise<void> = Promise.resolve();
private handleNotification(method: string, params: unknown): Promise<void> {
// An image must exist before its event, and completion must follow that event.
// Transport callbacks cannot await: the session owns their ordered drain.
const next = this.pendingNotifications.then(() => {
return this.closed ? undefined : this.processNotification(method, params);
});
this.pendingNotifications = next.catch((error: unknown) => {
this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error)));
});
return this.pendingNotifications;
}
private async processNotification(method: string, params: unknown): Promise<void> {
private handleNotification(method: string, params: unknown): void {
const notificationParams = toObjectRecord(params);
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
@@ -5485,16 +5467,13 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
if (route.kind === "sub_agent") {
await this.dispatchSubAgentNotification(parsed, route.callId);
this.dispatchSubAgentNotification(parsed, route.callId);
return;
}
await this.dispatchParsedNotification(parsed);
this.dispatchParsedNotification(parsed);
}
private async dispatchSubAgentNotification(
parsed: ParsedCodexNotification,
callId: string,
): Promise<void> {
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
@@ -5505,7 +5484,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "reasoning_delta":
case "item_started":
case "item_completed":
await this.dispatchParsedNotification(parsed);
this.dispatchParsedNotification(parsed);
return;
case "exec_command_output_delta":
case "file_change_output_delta":
@@ -5531,7 +5510,7 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise<void> {
private dispatchParsedNotification(parsed: ParsedCodexNotification): void {
if (isCodexDeltaNotification(parsed)) {
this.handleCodexDeltaNotification(parsed);
return;
@@ -5576,10 +5555,10 @@ export class CodexAppServerAgentSession implements AgentSession {
this.handlePatchApplyCompletedNotification(parsed);
return;
case "item_completed":
await this.handleItemCompletedNotification(parsed);
this.handleItemCompletedNotification(parsed);
return;
case "item_started":
await this.handleItemStartedNotification(parsed);
this.handleItemStartedNotification(parsed);
return;
case "invalid_payload":
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
@@ -5621,7 +5600,7 @@ export class CodexAppServerAgentSession implements AgentSession {
pending.push(parsed);
}
private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise<void> {
private replayPendingSubAgentNotifications(threadIds: readonly string[]): void {
for (const threadId of threadIds) {
const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
if (!pending) {
@@ -5633,7 +5612,7 @@ export class CodexAppServerAgentSession implements AgentSession {
continue;
}
for (const parsed of pending) {
await this.dispatchSubAgentNotification(parsed, callId);
this.dispatchSubAgentNotification(parsed, callId);
}
}
}
@@ -6599,13 +6578,9 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private ownsNotificationTurn(turnId: string | null): boolean {
return !this.closed && this.activeForegroundTurnId === turnId;
}
private async handleItemCompletedNotification(
private handleItemCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
): Promise<void> {
): void {
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
// and canonical `item/*`. Render ordinary items only from the canonical
// channel, but accept a legacy-only child announcement so it can establish
@@ -6615,23 +6590,20 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
if (this.isUserMessageItem(parsed.item)) {
await this.handleUserMessageItem(parsed);
this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
return;
}
const foregroundTurnId = this.activeForegroundTurnId;
const timelineItem = await threadItemToTimeline(parsed.item, {
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
if (!timelineItem) {
return;
}
const imageItems = await mcpToolResultImagesToTimeline(parsed.item);
if (!this.ownsNotificationTurn(foregroundTurnId)) return;
const registeredChildThreadIds =
timelineItem.type === "tool_call"
? this.registerSubAgentToolCall({
@@ -6641,11 +6613,12 @@ export class CodexAppServerAgentSession implements AgentSession {
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
})
: [];
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
if (childSubAgentCallId) {
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6653,7 +6626,7 @@ export class CodexAppServerAgentSession implements AgentSession {
);
const itemId = parsed.item.id;
if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) {
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
@@ -6664,7 +6637,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emittedItemCompletedIds.add(itemId);
this.emittedItemStartedIds.delete(itemId);
}
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
@@ -6693,7 +6666,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private consumeStreamedTextCompletion(
@@ -6765,14 +6738,14 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
private async handleItemStartedNotification(
private handleItemStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
): Promise<void> {
): void {
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
return;
}
if (this.isUserMessageItem(parsed.item)) {
await this.handleUserMessageItem(parsed);
this.handleUserMessageItem(parsed);
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
@@ -6794,7 +6767,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (this.handleRegisteredSubAgentActivity(parsed.item)) {
return;
}
const timelineItem = await threadItemToTimeline(parsed.item, {
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
});
@@ -6813,7 +6786,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
const normalizedItemType = normalizeCodexThreadItemType(
@@ -6837,14 +6810,14 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
}
private async handleUserMessageItem(
private handleUserMessageItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): Promise<void> {
): void {
const itemId = parsed.item.id;
const timelineItem = await threadItemToTimeline(parsed.item, {
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: true,
cwd: this.config.cwd ?? null,
});
@@ -1332,14 +1332,13 @@ describe("OMP agent client and session", () => {
expect(omp.runtimeLaunches()).toHaveLength(1);
});
test("rejects a pending recovery start and closes its processes when the session closes", async () => {
test("closes a replacement process if the session closes during relaunch", async () => {
const omp = new OmpHarness();
await omp.start();
omp.processExit("OMP RPC process exited with code 1 and signal null");
const turn = omp.startTurnDetached("continue");
const rejected = expect(turn).rejects.toThrow("closed");
await omp.close();
await rejected;
await turn;
await waitForImmediate();
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
});
@@ -1628,93 +1627,3 @@ test.each([
expect(await session.canStopRuntime!()).toBe(expected);
},
);
test.each([false, true])(
"image prompt keeps OMP's vision capability contract (vision: %s)",
async (vision) => {
const runtime = new FakeOmp();
runtime.queueSessionSetup((session) => {
session.state = {
...session.state,
model: {
provider: "test",
id: "image-model",
input: vision ? ["text", "image"] : ["text"],
},
};
});
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const bytes = Buffer.from("OMP attachment fixture");
const first = session.startTurn([
{ type: "text", text: "Describe this image." },
{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" },
]);
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
await first;
const [prompt] = runtime.latestSession().prompts;
expect(prompt.imageCount).toBe(vision ? 1 : 0);
if (vision) expect(prompt.message).toBe("Describe this image.");
else {
const imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1];
expect(imagePath).toBeTruthy();
const { readFile, rm } = await import("node:fs/promises");
expect(await readFile(imagePath!)).toEqual(bytes);
await rm(imagePath!);
}
},
);
test("interrupting OMP during image conversion cancels the admitted turn and permits a follow-up", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
onTestFinished(() => session.close());
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
let admitted = false;
const first = session
.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 53).toString("base64"),
mimeType: "image/png",
},
])
.then((result) => {
admitted = true;
return result;
});
await waitForImmediate();
expect(admitted).toBe(false);
expect(runtime.latestSession().prompts).toHaveLength(0);
await session.interrupt();
const followup = await session.startTurn("follow-up after image interrupt");
const original = await first;
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
);
expect(followup.turnId).not.toBe(original.turnId);
expect(runtime.latestSession().prompts).toEqual([
{ message: "follow-up after image interrupt", imageCount: 0 },
]);
});
test("closing OMP during image conversion rejects its pending start", async () => {
const runtime = new FakeOmp();
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
const first = session.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 54).toString("base64"),
mimeType: "image/png",
},
]);
const rejected = expect(first).rejects.toThrow("closed");
await waitForImmediate();
expect(runtime.latestSession().prompts).toHaveLength(0);
await session.close();
await rejected;
});
@@ -317,9 +317,9 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean
return model?.input?.includes("image") === true;
}
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
try {
const materialized = await materializeProviderImage({
const materialized = materializeProviderImage({
data: image.data,
mimeType: image.mimeType,
});
@@ -329,10 +329,10 @@ async function renderTextOnlyImageHint(image: { data: string; mimeType: string }
}
}
async function convertPromptInput(
function convertPromptInput(
prompt: AgentPromptInput,
options: { model: OmpModel | null | undefined },
): Promise<OmpPromptPayload> {
): OmpPromptPayload {
if (typeof prompt === "string") {
return { text: prompt };
}
@@ -355,7 +355,7 @@ async function convertPromptInput(
mimeType: block.mimeType,
});
} else {
textParts.push(await renderTextOnlyImageHint(block));
textParts.push(renderTextOnlyImageHint(block));
}
continue;
}
@@ -868,9 +868,11 @@ export class OmpAgentSession implements AgentSession {
throw new Error("An OMP turn is already active");
}
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
this.live = true;
this.activeTurnId = turnId;
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
this.activeAssistantMessageId = null;
this.activeTurnTerminalAssistantMessage = null;
this.activeTurnStarted = false;
@@ -878,10 +880,6 @@ export class OmpAgentSession implements AgentSession {
this.activeTurnHasUserMessage = false;
this.activePromptRequestId = null;
this.clearNoTurnBuffers();
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed) throw new Error("OMP session is closed");
if (this.activeTurnId !== turnId) return { turnId };
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
this.activeNoTurnPromptText = payload.text;
this.usagePoller.startTurn();
if (announceTurn) {
@@ -948,9 +946,7 @@ export class OmpAgentSession implements AgentSession {
): Promise<SteerResult> {
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
const payload = convertPromptInput(prompt, { model: this.state.model });
if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" };
const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text);
try {
@@ -1560,7 +1560,7 @@ describe("translateOpenCodeEvent", () => {
});
describe("OpenCode v2 history reconciliation", () => {
it("keeps client message identity on live echoes but omits it from history", async () => {
it("keeps client message identity on live echoes but omits it from history", () => {
const user: SessionMessageUser = {
id: "native-message",
type: "user",
@@ -1568,13 +1568,13 @@ describe("OpenCode v2 history reconciliation", () => {
time: { created: 1 },
metadata: { paseoClientMessageId: "client-message" },
};
expect(await new V2Timeline().messages([user])).toMatchObject([
expect(new V2Timeline().messages([user])).toMatchObject([
{ item: { clientMessageId: "client-message", messageId: "native-message" } },
]);
expect(await new V2Timeline(false).messages([user])).toMatchObject([
expect(new V2Timeline(false).messages([user])).toMatchObject([
{ item: { type: "user_message", messageId: "native-message" } },
]);
const [event] = await new V2Timeline(false).messages([user]);
const [event] = new V2Timeline(false).messages([user]);
expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId");
});
function assistant(text: string): SessionMessageAssistant {
@@ -1587,33 +1587,31 @@ describe("OpenCode v2 history reconciliation", () => {
content: [{ type: "text", text }],
};
}
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => {
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => {
const timeline = new V2Timeline();
const text = (messages: Awaited<ReturnType<V2Timeline["messages"]>>) =>
const text = (messages: ReturnType<V2Timeline["messages"]>) =>
messages.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
);
expect(text(await timeline.messages([assistant("hello")]))).toEqual(["hello"]);
expect(await timeline.messages([assistant("hel")])).toEqual([]);
expect(text(await timeline.messages([assistant("hello world")]))).toEqual([" world"]);
expect(await timeline.messages([assistant("hello world")])).toEqual([]);
expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]);
expect(timeline.messages([assistant("hel")])).toEqual([]);
expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]);
expect(timeline.messages([assistant("hello world")])).toEqual([]);
});
it("keeps reasoning separate from assistant output", async () => {
it("keeps reasoning separate from assistant output", () => {
const timeline = new V2Timeline();
const message = assistant("answer");
message.content.unshift({ type: "reasoning", text: "thinking" });
expect(
(await timeline.messages([message])).map((event) =>
event.type === "timeline" ? event.item : null,
),
timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)),
).toEqual([
{ type: "reasoning", text: "thinking" },
{ type: "assistant_message", text: "answer", messageId: "message" },
]);
});
it("emits tool state transitions once and keeps their call identity", async () => {
it("emits tool state transitions once and keeps their call identity", () => {
const timeline = new V2Timeline();
const message = assistant("");
message.content = [
@@ -1625,7 +1623,7 @@ describe("OpenCode v2 history reconciliation", () => {
state: { status: "running", input: { command: "pwd" }, metadata: {} },
},
];
expect(await timeline.messages([message])).toMatchObject([
expect(timeline.messages([message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "running" } },
]);
message.content = [
@@ -1641,9 +1639,9 @@ describe("OpenCode v2 history reconciliation", () => {
},
},
];
expect(await timeline.messages([message])).toMatchObject([
expect(timeline.messages([message])).toMatchObject([
{ item: { type: "tool_call", callId: "call", status: "completed" } },
]);
expect(await timeline.messages([message])).toEqual([]);
expect(timeline.messages([message])).toEqual([]);
});
});
@@ -21,18 +21,16 @@ for (let index = 0; index < chunks; index++) {
const event =
mode === "delta"
? timeline.delta({ ...part, delta })
: (
await timeline.messages([
{
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content: [{ type: "reasoning", text }],
},
])
)[0];
: timeline.messages([
{
id: "answer",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
time: { created: 2 },
content: [{ type: "reasoning", text }],
},
])[0];
if (event?.type !== "timeline") throw new Error("Expected a timeline event");
projection.append({
seq: index + 1,
@@ -92,7 +92,7 @@ export class SessionChildren {
this.childStates.set(info.id, signature);
this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation });
}
for (const event of await timeline.messages(await messages(this.options.client(), info.id))) {
for (const event of timeline.messages(await messages(this.options.client(), info.id))) {
if (event.type === "timeline")
this.options.emit({
type: "provider_subagent",
@@ -131,7 +131,7 @@ export class OpenCodeV2Session implements AgentSession {
await this.configureConnection();
const location = { directory: this.config.cwd };
this.modes = modesFromV2((await this.client.agent.list({ location })).data);
await this.timeline.messages(await messages(this.client, this.id));
this.timeline.messages(await messages(this.client, this.id));
await this.startStream();
await this.children.reconcile(this.id);
}
@@ -261,7 +261,7 @@ export class OpenCodeV2Session implements AgentSession {
async *streamHistory() {
await this.reconnectIfExited();
const history = new V2Timeline(false);
yield* await history.messages(await messages(this.client, this.id));
yield* history.messages(await messages(this.client, this.id));
}
async getRuntimeInfo() {
await this.reconnectIfExited();
@@ -417,7 +417,7 @@ export class OpenCodeV2Session implements AgentSession {
]);
this.info = info;
this.history = history;
for (const event of await this.timeline.messages(history)) this.emitTimeline(event);
for (const event of this.timeline.messages(history)) this.emitTimeline(event);
await this.permissions.reconcile(this.id);
}
private async reconcile() {
@@ -80,7 +80,7 @@ describe("OpenCode v2 token streaming", () => {
}
});
test("deduplicates snapshots before and after deltas using per-type ordinals", async () => {
test("deduplicates snapshots before and after deltas using per-type ordinals", () => {
const timeline = new V2Timeline();
const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
const reasoning = { ...text, type: "reasoning" } as const;
@@ -99,24 +99,24 @@ describe("OpenCode v2 token streaming", () => {
},
{ type: "text", text: "Hello" },
]);
expect(await timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]);
expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]);
expect(timeline.delta({ ...text, delta: "lo" })).toBeNull();
expect(await timeline.messages([snapshot])).toEqual([]);
expect(timeline.messages([snapshot])).toEqual([]);
expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } });
expect(await timeline.messages([snapshot])).toEqual([]);
expect(timeline.messages([snapshot])).toEqual([]);
});
test("recovers missed fragments from snapshots after reconnect", async () => {
test("recovers missed fragments from snapshots after reconnect", () => {
const timeline = new V2Timeline();
const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
timeline.startPart(part);
expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } });
timeline.resetStreams();
expect(timeline.delta({ ...part, delta: "!" })).toBeNull();
expect(await timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([
{ item: { text: "lo!" } },
]);
expect(await timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]);
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]);
});
test("withholds streamed prose during a structured-output turn", async () => {
@@ -15,7 +15,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA
};
}
test("a completed edit carries the replaced and replacement text", async () => {
test("a completed edit carries the replaced and replacement text", () => {
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
const edit = assistant([
{
@@ -45,7 +45,7 @@ test("a completed edit carries the replaced and replacement text", async () => {
},
]);
expect(await new V2Timeline().messages([edit])).toMatchObject([
expect(new V2Timeline().messages([edit])).toMatchObject([
{
item: {
type: "tool_call",
@@ -57,7 +57,7 @@ test("a completed edit carries the replaced and replacement text", async () => {
]);
});
test("a tool's image output is shown as an image, not as base64 in the tool output", async () => {
test("a tool's image output is shown as an image, not as base64 in the tool output", () => {
const png =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
const screenshot = assistant([
@@ -80,7 +80,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
]);
const timeline = new V2Timeline();
const events = await timeline.messages([screenshot]);
const events = timeline.messages([screenshot]);
expect(JSON.stringify(events)).not.toContain(png);
expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([
@@ -95,7 +95,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/),
},
]);
expect(await timeline.messages([screenshot])).toEqual([]);
expect(timeline.messages([screenshot])).toEqual([]);
});
test("a surrogate pair split across deltas streams back intact", () => {
@@ -55,15 +55,7 @@ export class V2Timeline {
return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() };
}
private pendingMessages: Promise<unknown> = Promise.resolve();
messages(messages: SessionMessageInfo[]): Promise<AgentStreamEvent[]> {
const result = this.pendingMessages.then(() => this.projectMessages(messages));
this.pendingMessages = result.catch(() => {});
return result;
}
private async projectMessages(messages: SessionMessageInfo[]): Promise<AgentStreamEvent[]> {
messages(messages: SessionMessageInfo[]): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
const state: TimelineState = { structured: false, accepted: false };
for (const message of messages) {
@@ -71,7 +63,7 @@ export class V2Timeline {
const push = (item: AgentTimelineItem) =>
events.push({ type: "timeline", provider: "opencode", item, timestamp });
if (message.type === "user") this.userMessage(message, state, push);
else if (message.type === "assistant") await this.assistantMessage(message, state, push);
else if (message.type === "assistant") this.assistantMessage(message, state, push);
else if (message.type === "compaction") this.compactionMessage(message, push);
}
return events;
@@ -97,7 +89,7 @@ export class V2Timeline {
});
}
private async assistantMessage(
private assistantMessage(
message: Extract<SessionMessageInfo, { type: "assistant" }>,
state: TimelineState,
push: (item: AgentTimelineItem) => void,
@@ -107,16 +99,16 @@ export class V2Timeline {
// paths address the same cursor entry.
let textOrdinal = 0;
let reasoningOrdinal = 0;
for (const [partIndex, part] of message.content.entries()) {
message.content.forEach((part, partIndex) => {
if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) {
this.structuredOutputPart(message.id, partIndex, part, state, push);
} else if (part.type === "text" || part.type === "reasoning") {
const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++;
this.textPart(message.id, part, ordinal, state, push);
} else {
await this.toolPart(message.id, partIndex, part, push);
this.toolPart(message.id, partIndex, part, push);
}
}
});
}
private structuredOutputPart(
@@ -164,7 +156,7 @@ export class V2Timeline {
else push({ type: "reasoning", text: suffix });
}
private async toolPart(
private toolPart(
messageID: string,
partIndex: number,
part: Extract<
@@ -182,16 +174,16 @@ export class V2Timeline {
if (item) push(item);
// Images follow their tool call as markdown, as Claude and Codex emit them,
// so base64 never reaches the tool output.
for (const [index, image] of images.entries()) {
images.forEach((image, index) => {
const imageKey = `${key}:image:${index}`;
if (this.content.has(imageKey)) continue;
if (this.content.has(imageKey)) return;
this.content.set(imageKey, "");
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(
const imageItem = renderProviderImageOutputAsAssistantMarkdown(
{ url: image.uri, mimeType: image.mime },
{ materialize: materializeProviderImage },
);
if (imageItem) push(imageItem);
}
});
}
private compactionMessage(
@@ -3583,63 +3583,3 @@ test("pi runtime safety protects queued replies and runtime-owned activity", asy
native.getStateError = new Error("Unable to prove idle");
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
});
test("reserves the Pi turn while an image prompt is materialized", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const first = session.startTurn([
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
]);
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
await first;
expect(pi.latestSession().prompts).toHaveLength(1);
});
test("interrupting Pi during image conversion cancels the admitted turn and permits a follow-up", async () => {
const { pi, session } = await createSession();
onTestFinished(() => session.close());
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
let admitted = false;
const first = session
.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 51).toString("base64"),
mimeType: "image/png",
},
])
.then((result) => {
admitted = true;
return result;
});
await waitForImmediate();
expect(admitted).toBe(false);
expect(pi.latestSession().prompts).toHaveLength(0);
await session.interrupt();
const followup = await session.startTurn("follow-up after image interrupt");
const original = await first;
expect(events).toContainEqual(
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
);
expect(followup.turnId).not.toBe(original.turnId);
expect(pi.latestSession().prompts).toEqual([
{ message: "follow-up after image interrupt", imageCount: 0 },
]);
});
test("closing Pi during image conversion rejects its pending start", async () => {
const { pi, session } = await createSession();
const first = session.startTurn([
{
type: "image",
data: Buffer.alloc(16 * 1024 * 1024, 52).toString("base64"),
mimeType: "image/png",
},
]);
const rejected = expect(first).rejects.toThrow("closed");
await waitForImmediate();
expect(pi.latestSession().prompts).toHaveLength(0);
await session.close();
await rejected;
});
@@ -367,9 +367,9 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean {
return model?.input?.includes("image") === true;
}
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
try {
const materialized = await materializeProviderImage({
const materialized = materializeProviderImage({
data: image.data,
mimeType: image.mimeType,
});
@@ -379,10 +379,10 @@ async function renderTextOnlyImageHint(image: { data: string; mimeType: string }
}
}
async function convertPromptInput(
function convertPromptInput(
prompt: AgentPromptInput,
options: { model: PiModel | null | undefined },
): Promise<PiPromptPayload> {
): PiPromptPayload {
if (typeof prompt === "string") {
return { text: prompt };
}
@@ -405,7 +405,7 @@ async function convertPromptInput(
mimeType: block.mimeType,
});
} else {
textParts.push(await renderTextOnlyImageHint(block));
textParts.push(renderTextOnlyImageHint(block));
}
continue;
}
@@ -1280,6 +1280,7 @@ export class PiRpcAgentSession implements AgentSession {
throw new Error("A Pi turn is already active");
}
const payload = convertPromptInput(prompt, { model: this.state.model });
const turnId = randomUUID();
this.activeTurnId = turnId;
this.usagePoller.startTurn();
@@ -1291,9 +1292,6 @@ export class PiRpcAgentSession implements AgentSession {
this.activePromptRequestId = null;
this.pendingSteerSubmissions.length = 0;
this.clearNoTurnBuffers();
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed) throw new Error("Pi session is closed");
if (this.activeTurnId !== turnId) return { turnId };
this.activeNoTurnPromptText = payload.text;
const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null;
@@ -1365,9 +1363,7 @@ export class PiRpcAgentSession implements AgentSession {
if (this.closed || this.activeTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed || this.activeTurnId !== options.expectedTurnId)
return { status: "unavailable" };
const payload = convertPromptInput(prompt, { model: this.state.model });
// Pi rejects steer RPCs that are extension commands, so slash inputs keep the
// interrupt-and-replace fallback where they can run directly.
if (this.parseSlashCommandInput(payload.text)) {
@@ -5,8 +5,8 @@ import { describe, expect, test } from "vitest";
import { materializeProviderImage } from "./provider-image-output.js";
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
test("writes image attachments under a private temp directory", async () => {
const materialized = await materializeProviderImage({
test("writes image attachments under a private temp directory", () => {
const materialized = materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
@@ -1,11 +1,10 @@
import { existsSync, rmSync, statSync, readFileSync } from "node:fs";
import { existsSync, rmSync } from "node:fs";
import path from "node:path";
import MarkdownIt from "markdown-it";
import { projectTimelineRows } from "../timeline-projection.js";
import { describe, expect, test } from "vitest";
import {
MAX_PROVIDER_IMAGE_BYTES,
isProviderImageMarkdown,
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
@@ -13,8 +12,8 @@ import {
const HASH = "a".repeat(64);
async function renderImageMarkdown(imagePath: string): Promise<string> {
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
function renderImageMarkdown(imagePath: string): string {
const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
if (!item || item.type !== "assistant_message") {
throw new Error("Expected provider image output to render as assistant markdown.");
}
@@ -41,8 +40,8 @@ describe("isProviderImageMarkdown", () => {
).toBe(true);
});
test("emits Windows file paths as file URIs", async () => {
const markdown = await renderImageMarkdown(
test("emits Windows file paths as file URIs", () => {
const markdown = renderImageMarkdown(
`C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`,
);
@@ -52,24 +51,22 @@ describe("isProviderImageMarkdown", () => {
expect(isProviderImageMarkdown(markdown)).toBe(true);
});
test("emits POSIX file paths with spaces as valid file URI markdown", async () => {
const markdown = await renderImageMarkdown(
"/home/user/Projects/Project With Spaces/screenshot.png",
);
test("emits POSIX file paths with spaces as valid file URI markdown", () => {
const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png");
expect(markdown).toBe(
"\n\n![Image](file:///home/user/Projects/Project%20With%20Spaces/screenshot.png)\n\n",
);
});
test("encodes URI-significant characters in POSIX file paths", async () => {
const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png");
test("encodes URI-significant characters in POSIX file paths", () => {
const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png");
expect(markdown).toBe("\n\n![Image](file:///tmp/screenshot%231%3Fdraft.png)\n\n");
});
test("preserves double-leading slashes in POSIX file paths", async () => {
const markdown = await renderImageMarkdown("//tmp/screenshot#1.png");
test("preserves double-leading slashes in POSIX file paths", () => {
const markdown = renderImageMarkdown("//tmp/screenshot#1.png");
expect(markdown).toBe("\n\n![Image](file:////tmp/screenshot%231.png)\n\n");
});
@@ -81,8 +78,8 @@ describe("isProviderImageMarkdown", () => {
"\\\\?\\UNC\\server\\share\\shot?draft.png",
"file://server/share/shot%3Fdraft.png",
],
])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => {
expect(await renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`);
])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => {
expect(renderImageMarkdown(imagePath)).toBe(`\n\n![Image](${expectedSource})\n\n`);
});
test("rejects user-authored markdown that is not a materialized attachment", () => {
@@ -95,8 +92,8 @@ describe("isProviderImageMarkdown", () => {
});
describe("materializeProviderImage", () => {
test("recreates the private temp directory if the cached directory is removed", async () => {
const first = await materializeProviderImage({
test("recreates the private temp directory if the cached directory is removed", () => {
const first = materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
@@ -105,7 +102,7 @@ describe("materializeProviderImage", () => {
rmSync(firstDir, { recursive: true, force: true });
const second = await materializeProviderImage({
const second = materializeProviderImage({
data: "ZGVmNDU2",
mimeType: "image/png",
});
@@ -120,8 +117,8 @@ describe("materializeProviderImage", () => {
});
describe("provider image paragraph boundaries", () => {
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => {
const image = await renderProviderImageOutputAsAssistantMarkdown({
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => {
const image = renderProviderImageOutputAsAssistantMarkdown({
url: "https://example.com/chart.png",
});
if (!image) throw new Error("Expected image output");
@@ -155,10 +152,10 @@ describe("provider image paragraph boundaries", () => {
});
});
test("materialized image output retains paragraph boundaries and attachment recognition", async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
test("materialized image output retains paragraph boundaries and attachment recognition", () => {
const item = renderProviderImageOutputAsAssistantMarkdown(
{ data: "YWJjMTIz", mimeType: "image/png" },
{ materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
{ materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
);
expect(item).toEqual({
type: "assistant_message",
@@ -167,76 +164,3 @@ test("materialized image output retains paragraph boundaries and attachment reco
if (!item || item.type !== "assistant_message") throw new Error("Expected image output");
expect(isProviderImageMarkdown(item.text)).toBe(true);
});
test("repeated content is reused without rewriting the attachment", async () => {
const image = {
data: Buffer.from("dedupe regression").toString("base64"),
mimeType: "image/png",
};
const first = await materializeProviderImage(image);
const before = statSync(first.path, { bigint: true });
await new Promise((resolve) => setTimeout(resolve, 20));
const second = await materializeProviderImage(image);
expect(second.path).toBe(first.path);
expect(statSync(second.path, { bigint: true }).mtimeNs).toBe(before.mtimeNs);
});
test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => {
const bytes = Buffer.alloc(64 * 1024 * 1024, 37);
const data = bytes.toString("base64");
let previous = performance.now();
let longest = 0;
const probe = setInterval(() => {
const now = performance.now();
longest = Math.max(longest, now - previous);
previous = now;
}, 1);
try {
const result = await materializeProviderImage({ data, mimeType: "image/png" });
await new Promise((resolve) => setTimeout(resolve, 5));
console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`);
expect(longest).toBeLessThan(50);
expect(readFileSync(result.path).equals(bytes)).toBe(true);
rmSync(result.path, { force: true });
} finally {
clearInterval(probe);
}
});
test("preserves base64 decoding across whitespace and chunk boundaries", async () => {
const bytes = Buffer.alloc(512 * 1024, 123);
const base64 = bytes.toString("base64");
const data = base64.match(/.{1,77}/g)!.join("\n");
const result = await materializeProviderImage({ data, mimeType: "image/png" });
expect(readFileSync(result.path).equals(bytes)).toBe(true);
});
test("rejects oversized materialization before work and leaves the queue usable", async () => {
const data = "A".repeat(Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4 + 260);
await expect(materializeProviderImage({ data, mimeType: "image/png" })).rejects.toThrow(
"exceeds",
);
const omitted = await renderProviderImageOutputAsAssistantMarkdown(
{ data, mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
expect(omitted).toEqual({
type: "assistant_message",
text: "Image output was omitted because it was not available as a file path or URL.",
});
const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
expect(readFileSync(image.path).toString()).toBe("abc123");
});
test("simultaneous requests for the same content share the published file", async () => {
const image = {
data: Buffer.from("concurrent dedupe fixture").toString("base64"),
mimeType: "image/png",
};
const results = await Promise.all([
materializeProviderImage(image),
materializeProviderImage(image),
]);
expect(results[0]).toEqual(results[1]);
expect(readFileSync(results[0].path).toString()).toBe("concurrent dedupe fixture");
});
@@ -1,6 +1,5 @@
import { createHash } from "node:crypto";
import * as fs from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import * as fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -24,26 +23,32 @@ const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
let materializedImageAttachmentDir: string | null = null;
// Serialize materializations: one decoder and one file operation at a time, including
// concurrent history replay and live turns. A rejected operation must not poison the queue.
let pendingMaterialization: Promise<unknown> = Promise.resolve();
async function getMaterializedImageAttachmentDir(): Promise<string> {
if (materializedImageAttachmentDir) {
try {
const stats = await fs.lstat(materializedImageAttachmentDir);
if (stats.isDirectory()) {
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
}
} catch {
/* Temp storage may have been removed between turns. */
function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
try {
const stats = fsSync.lstatSync(dir);
if (!stats.isDirectory()) {
return false;
}
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
return true;
} catch {
return false;
}
materializedImageAttachmentDir = await fs.mkdtemp(
}
function getMaterializedImageAttachmentDir(): string {
if (
materializedImageAttachmentDir &&
canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir)
) {
return materializedImageAttachmentDir;
}
materializedImageAttachmentDir = fsSync.mkdtempSync(
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
);
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
}
@@ -66,92 +71,32 @@ function getImageExtension(mimeType: string): string {
}
}
// 128 MiB admits the measured 64 MiB fixture with headroom while bounding disk
// and decoding per image. This is materialization admission, not a wire schema limit.
export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024;
const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned.
function normalizeImageData(mimeType: string, data: string): { mimeType: string; data: string } {
if (data.startsWith("data:")) {
const match = data.match(/^data:([^;]+);base64,(.*)$/);
if (match) {
return { mimeType: match[1], data: match[2] };
}
}
return { mimeType, data };
}
// Filenames are a content hash of the bytes so re-materializing the same image
// within a process reuses the existing temp file instead of leaking a fresh one
// for repeated image blocks or history replay.
export function materializeProviderImage(image: {
data: string;
mimeType: string | null;
}): Promise<MaterializedProviderImage> {
const result = pendingMaterialization.then(() => materializeImage(image));
pendingMaterialization = result.catch(() => {});
return result;
}
async function materializeImage(image: {
data: string;
mimeType: string | null;
}): Promise<MaterializedProviderImage> {
const maxEncodedCharacters = Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4;
// Check length before inspecting/copying the payload, including a bounded data URI header.
if (image.data.length > maxEncodedCharacters + 256) {
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
}
let data = image.data;
let mimeType = image.mimeType ?? "image/png";
if (data.startsWith("data:")) {
const separator = data.indexOf(";base64,");
if (separator >= 0 && separator < 256) {
mimeType = data.slice(5, separator);
data = data.slice(separator + 8);
}
}
if (data.length > maxEncodedCharacters) {
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
}
const hash = createHash("sha256");
for await (const bytes of decodeImage(data)) hash.update(bytes);
const dir = await getMaterializedImageAttachmentDir();
const filePath = path.join(dir, `${hash.digest("hex")}.${getImageExtension(mimeType)}`);
try {
await fs.stat(filePath);
return { path: filePath };
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const staging = await fs.mkdtemp(path.join(dir, ".pending-"));
const stagingPath = path.join(staging, "image");
try {
await fs.chmod(staging, PRIVATE_ATTACHMENT_DIR_MODE);
const file = await fs.open(stagingPath, "wx", MATERIALIZED_IMAGE_FILE_MODE);
try {
await file.chmod(MATERIALIZED_IMAGE_FILE_MODE);
// Decode again only for a new hash. This bounds retained memory to one chunk
// and performs no writes at all for repeated content, including history replay.
for await (const bytes of decodeImage(data)) await file.writeFile(bytes);
} finally {
await file.close();
}
await fs.rename(stagingPath, filePath);
return { path: filePath };
} finally {
await fs.rm(staging, { recursive: true, force: true });
}
}
async function* decodeImage(data: string): AsyncGenerator<Buffer> {
let carry = "";
let decodedBytes = 0;
for (let offset = 0; offset < data.length; offset += BASE64_CHUNK_CHARACTERS) {
// Buffer.from ignores non-base64 characters. Carry incomplete quartets so
// line-wrapped payloads decode identically across chunk boundaries.
const chunk =
carry +
data.slice(offset, offset + BASE64_CHUNK_CHARACTERS).replace(/[^A-Za-z0-9+/_=-]/g, "");
const finalChunk = offset + BASE64_CHUNK_CHARACTERS >= data.length || chunk.includes("=");
const end = finalChunk ? chunk.length : chunk.length - (chunk.length % 4);
const bytes = Buffer.from(chunk.slice(0, end), "base64");
carry = chunk.slice(end);
decodedBytes += bytes.length;
if (decodedBytes > MAX_PROVIDER_IMAGE_BYTES)
throw new Error("Image attachment exceeds size limit");
yield bytes;
await setImmediate();
if (finalChunk) break;
}
}): MaterializedProviderImage {
const attachmentsDir = getMaterializedImageAttachmentDir();
const normalized = normalizeImageData(image.mimeType ?? "image/png", image.data);
const bytes = Buffer.from(normalized.data, "base64");
const extension = getImageExtension(normalized.mimeType);
const hash = createHash("sha256").update(bytes).digest("hex");
const filePath = path.join(attachmentsDir, `${hash}.${extension}`);
fsSync.writeFileSync(filePath, bytes, { mode: MATERIALIZED_IMAGE_FILE_MODE });
fsSync.chmodSync(filePath, MATERIALIZED_IMAGE_FILE_MODE);
return { path: filePath };
}
// Recognizes markdown rendered for a materialized provider image: its source is a content-hashed
@@ -167,10 +112,7 @@ export function isProviderImageMarkdown(text: string): boolean {
}
interface RenderProviderImageOutputOptions {
materialize?: (image: {
data: string;
mimeType: string | null;
}) => Promise<MaterializedProviderImage>;
materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage;
}
function nonEmptyString(value: string | null | undefined): string | null {
@@ -227,10 +169,10 @@ function escapeMarkdownImageSource(value: string): string {
return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
}
export async function renderProviderImageOutputAsAssistantMarkdown(
export function renderProviderImageOutputAsAssistantMarkdown(
image: ProviderImageOutput,
options: RenderProviderImageOutputOptions = {},
): Promise<AgentTimelineItem | null> {
): AgentTimelineItem | null {
const source = nonEmptyString(image.path) ?? nonEmptyString(image.url);
if (source && !isDataImageSource(source)) {
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
@@ -249,7 +191,7 @@ export async function renderProviderImageOutputAsAssistantMarkdown(
let materialized: MaterializedProviderImage | null = null;
try {
materialized = options.materialize
? await options.materialize({
? options.materialize({
data,
mimeType: nonEmptyString(image.mimeType),
})