Rewind chat or files from any user message (#1154)

* Add Pi extension-launch plumbing for in-process tree primitives

* Add Rewind controls for agent sessions

* Preserve SDK checkpoint env var so Claude file rewind works

The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.

Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.

* Update OpenCode history replay tests for rewind state

* Show Rewind actions in an adaptive sheet

* Fix rewind semantics across providers

* Refactor Pi IDs through extension capture

* Restore user message to composer after rewind

* Fix Claude rewind tool result mapping

* Fix Claude rewind session switch roundtrip

* Fix rewind and optimistic message regressions

* Tighten rewind test discipline

* Fix OpenCode optimistic message reconciliation

* Reconcile optimistic user messages by marker

* Reorder user message actions

* Unify provider user message contract

* Add Claude rewind flow Playwright contract

* Add Codex rewind flow Playwright contract

* Add OpenCode rewind flow Playwright contract

* Add Pi rewind flow Playwright contract

* Remove real-provider rewind Playwright contracts

* Gate real-provider rewind specs from CI Playwright
This commit is contained in:
Mohamed Boudra
2026-05-25 17:11:34 +08:00
committed by GitHub
parent e966f70322
commit 132e572d6e
99 changed files with 7106 additions and 3425 deletions
+2
View File
@@ -28,6 +28,8 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
---
+1 -1
View File
@@ -111,7 +111,7 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E).
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
### Test setup
+12 -3
View File
@@ -537,13 +537,22 @@ async function awaitRelayReady(
}
}
async function startRelay(): Promise<number> {
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
for (;;) {
const port = await getAvailablePort();
if (!excludedPorts.has(port)) {
return port;
}
}
}
async function startRelay(excludedPorts: Set<number>): Promise<number> {
const relayDir = path.resolve(__dirname, "..", "..", "relay");
const maxRelayStartupAttempts = 5;
let lastRelayStartupError: unknown = null;
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
const relayPort = await getAvailablePort();
const relayPort = await getAvailablePortExcluding(excludedPorts);
const buffer = createLineBuffer();
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
@@ -729,7 +738,7 @@ export default async function globalSetup() {
const dictation = await resolveDictationConfig();
try {
const relayPort = await startRelay();
const relayPort = await startRelay(new Set([port, metroPort]));
metroProcess = startMetro(metroPort, metroLineBuffer);
daemonProcess = startDaemon({
port,
+342
View File
@@ -0,0 +1,342 @@
import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expectComposerEditable, submitMessage } from "./composer";
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
export type RewindFlowMode = "conversation" | "files" | "both";
export interface AgentHandle {
page: Page;
client: TerminalPerfDaemonClient;
agentId: string;
cwd: string;
provider: RewindFlowProvider;
}
export interface TranscriptMessage {
role: "user" | "assistant";
text: string | RegExp;
}
interface ProviderLaunchConfig {
provider: RewindFlowProvider;
model?: string;
thinkingOptionId?: string;
modeId?: string;
}
const SEND_TIMEOUT_MS = 240_000;
const REWIND_TIMEOUT_MS = 120_000;
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
switch (provider) {
case "claude":
return { provider, model: "haiku", modeId: "bypassPermissions" };
case "codex":
return {
provider,
model: "gpt-5.4-mini",
thinkingOptionId: "low",
modeId: "full-access",
};
case "opencode":
return { provider, model: "opencode/big-pickle", modeId: "full-access" };
case "pi":
return {
provider,
model: "openrouter/google/gemini-2.5-flash-lite",
thinkingOptionId: "medium",
};
}
}
function agentRoute(cwd: string, agentId: string): string {
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
`agent:${agentId}`,
)}`;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
await page.goto(agentRoute(input.cwd, input.agentId));
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await assertComposerIdle({ page });
}
function visibleChatMessages(page: Page) {
return page
.locator('[data-testid="agent-chat-scroll"]:visible')
.first()
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
}
async function transcript(
page: Page,
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
elements.map((element) => ({
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
| "user"
| "assistant",
text: (element.textContent ?? "")
.replace(/\s+/g, " ")
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
.trim(),
})),
);
return coalesceAssistantTurnSegments(rawMessages);
}
function coalesceAssistantTurnSegments(
messages: Array<{ role: "user" | "assistant"; text: string }>,
): Array<{ role: "user" | "assistant"; text: string }> {
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
for (const message of messages) {
const previous = transcriptMessages.at(-1);
if (message.role === "assistant" && previous?.role === "assistant") {
const joinedText =
previous.text && message.text
? `${previous.text}\n${message.text}`
: previous.text || message.text;
transcriptMessages[transcriptMessages.length - 1] = {
role: "assistant",
text: joinedText,
};
continue;
}
transcriptMessages.push(message);
}
return transcriptMessages.filter(
(message) => message.role !== "assistant" || message.text.length > 0,
);
}
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
if (typeof expected === "string") {
return actual === expected;
}
return expected.test(actual);
}
function formatExpectedMessage(message: TranscriptMessage): string {
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
return `${message.role}:${text}`;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
export async function launchAgent(input: {
page: Page;
provider: RewindFlowProvider;
cwd: string;
mode: "full-access";
}): Promise<AgentHandle> {
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], {
cwd: input.cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: input.cwd,
stdio: "ignore",
});
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
const client = await connectTerminalClient();
const opened = await client.openProject(input.cwd);
if (!opened.workspace) {
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
}
const agent = await client.createAgent({
...fullAccessConfig(input.provider),
cwd: input.cwd,
title: `rewind-flow-${input.provider}-${randomUUID()}`,
});
const handle = {
page: input.page,
client,
agentId: agent.id,
cwd: input.cwd,
provider: input.provider,
};
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
return handle;
}
export async function closeAgent(handle: AgentHandle): Promise<void> {
await handle.client.close().catch(() => undefined);
}
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
const before = await transcript(handle.page);
await submitMessage(handle.page, text);
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
if (finish.status !== "idle") {
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
throw new Error(
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
);
}
if (finish.final?.lastError) {
throw new Error(finish.final.lastError);
}
await expect
.poll(async () => transcript(handle.page), { timeout: 30_000 })
.toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "user", text }),
expect.objectContaining({ role: "assistant" }),
]),
);
await expect
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
timeout: 30_000,
})
.toBeGreaterThanOrEqual(before.length + 2);
await assertComposerIdle(handle);
}
export async function assertChatTranscript(
handle: Pick<AgentHandle, "page">,
expected: TranscriptMessage[],
): Promise<void> {
await expect
.poll(
async () => {
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
return JSON.stringify(actual);
}
const matches = actual.every(
(message, index) =>
message.role === expected[index]?.role &&
expectedTextMatches(message.text, expected[index]!.text),
);
return matches ? "match" : JSON.stringify(actual);
},
{ timeout: 30_000 },
)
.toBe("match");
const actual = await transcript(handle.page);
if (actual.length !== expected.length) {
throw new Error(
`Expected ${expected.length} chat messages (${expected
.map(formatExpectedMessage)
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
);
}
}
export async function rewindMessage(
handle: AgentHandle,
userMessageIndex: number,
mode: RewindFlowMode,
): Promise<void> {
const beforeEpoch = await fetchTimelineEpoch(handle);
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
await expect(userMessage).toBeVisible({ timeout: 30_000 });
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
const userText = userMessages[userMessageIndex]?.text;
if (!userText) {
throw new Error(`No user message found at index ${userMessageIndex}`);
}
await userMessage
.getByText(new RegExp(escapeRegExp(userText)))
.first()
.hover();
const trigger = userMessage.getByTestId("rewind-menu-trigger");
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
await expect(modeItem).toBeVisible({ timeout: 10_000 });
await modeItem.click();
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
if (mode !== "files") {
await waitForNextTimelineEpoch(handle, beforeEpoch);
}
await assertComposerIdle(handle);
}
export async function assertFileExists(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
}
export async function assertFileMissing(filePath: string): Promise<void> {
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
}
export async function assertFileContains(filePath: string, text: string): Promise<void> {
await assertFileExists(filePath);
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
}
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
await expectComposerEditable(handle.page);
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 30_000,
});
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
timeout: 30_000,
});
}
export async function cleanupRewindFlow(input: {
handle?: AgentHandle;
cwd: string;
}): Promise<void> {
if (input.handle) {
await closeAgent(input.handle);
}
rmSync(input.cwd, { recursive: true, force: true });
}
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
const client = handle.client as TerminalPerfDaemonClient & {
fetchAgentTimeline: (
agentId: string,
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
) => Promise<{ epoch?: string }>;
};
const timeline = await client.fetchAgentTimeline(handle.agentId, {
direction: "tail",
projection: "canonical",
limit: 0,
});
return timeline.epoch;
}
async function waitForNextTimelineEpoch(
handle: AgentHandle,
beforeEpoch: string | undefined,
): Promise<void> {
await expect
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
.not.toBe(beforeEpoch);
}
+16
View File
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
@@ -35,6 +36,10 @@ export interface TerminalPerfDaemonClient {
timeout?: number,
): Promise<{ status: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
waitForFinish(
agentId: string,
timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
subscribeTerminal(
terminalId: string,
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
@@ -68,6 +73,7 @@ interface TerminalPerfDaemonClientConfig {
url: string;
clientId: string;
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
}
@@ -91,12 +97,22 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
url: getDaemonWsUrl(),
clientId: `terminal-perf-${randomUUID()}`,
clientType: "cli",
appVersion: loadAppVersion(),
webSocketFactory,
});
await client.connect();
return client;
}
function loadAppVersion(): string {
const packageJsonPath = path.resolve(__dirname, "../../package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error(`Missing app version in ${packageJsonPath}`);
}
return packageJson.version;
}
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
const serverId = getServerId();
const route = buildHostWorkspaceRoute(serverId, workspaceId);
@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "claude",
rewindMode: "both",
fileReverted: true,
});
@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "codex",
rewindMode: "conversation",
fileReverted: false,
});
@@ -0,0 +1,8 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "opencode",
initialRewindMode: "both",
rewindMode: "both",
fileReverted: true,
});
@@ -0,0 +1,7 @@
import { defineRewindFlowSpec } from "./rewind-flow.shared";
defineRewindFlowSpec({
provider: "pi",
rewindMode: "conversation",
fileReverted: false,
});
+88
View File
@@ -0,0 +1,88 @@
import { mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "./fixtures";
import {
assertChatTranscript,
assertComposerIdle,
assertFileContains,
assertFileExists,
assertFileMissing,
cleanupRewindFlow,
launchAgent,
rewindMessage,
sendMessage,
type AgentHandle,
type RewindFlowMode,
type RewindFlowProvider,
} from "./helpers/rewind-flow";
const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN";
interface RewindFlowCase {
provider: RewindFlowProvider;
initialRewindMode?: RewindFlowMode;
rewindMode: RewindFlowMode;
fileReverted: boolean;
}
export function defineRewindFlowSpec(input: RewindFlowCase): void {
test.describe(`rewind flow - ${input.provider}`, () => {
test.setTimeout(600_000);
test("rewinds conversation and file-write turns without transcript drift", async ({ page }) => {
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-rewind-flow-${input.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: input.provider,
cwd,
mode: "full-access",
});
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 0, input.initialRewindMode ?? "conversation");
await assertChatTranscript(handle, []);
await assertComposerIdle(handle);
await sendMessage(handle, "hello");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await sendMessage(handle, FILE_PROMPT);
await assertFileContains(path.join(cwd, "qa.txt"), "PASEO_QA_TOKEN");
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
{ role: "user", text: /Use the Write tool/ },
{ role: "assistant", text: /.+/ },
]);
await rewindMessage(handle, 1, input.rewindMode);
if (input.fileReverted) {
await assertFileMissing(path.join(cwd, "qa.txt"));
} else {
await assertFileExists(path.join(cwd, "qa.txt"));
}
await assertChatTranscript(handle, [
{ role: "user", text: "hello" },
{ role: "assistant", text: /.+/ },
]);
await assertComposerIdle(handle);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
}
@@ -0,0 +1,169 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "./helpers/composer";
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected);
}
test.describe("Rewind sheet", () => {
test("rewinds from a user message sheet option", async ({ page }) => {
const repo = await createTempGitRepo("rewind-e2e-");
const client = await connectTerminalClient();
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
const secondPrompt = "Prepare deleted rewind turn assistant content.";
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind e2e",
modeId: "load-test",
model: "ten-second-stream",
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await expectUserMessageCount(page, 1);
await submitMessage(page, secondPrompt);
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
await expectUserMessageCount(page, 2);
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 1);
await expectComposerDraft(page, firstPrompt);
await submitMessage(page, replacementPrompt);
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, "");
await expectUserMessageCount(page, 2);
const preservedDraft = "Keep this human draft after rewind.";
await fillComposerDraft(page, preservedDraft);
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-files").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, preservedDraft);
await expectUserMessageCount(page, 2);
await fillComposerDraft(page, "");
await composerLocator(page).evaluate((element) => element.blur());
await page.getByText(replacementPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").last().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
await page.getByTestId("rewind-menu-both").click();
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
await expectComposerDraft(page, replacementPrompt);
await expectUserMessageCount(page, 1);
} finally {
await client.close();
await repo.cleanup();
}
});
test("surfaces rewind failures without crashing the page", async ({ page }) => {
const repo = await createTempGitRepo("rewind-failure-e2e-");
const client = await connectTerminalClient();
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "Rewind failure e2e",
modeId: "load-test",
model: "ten-second-stream",
featureValues: {
mockRewindError: rewindError,
},
initialPrompt: firstPrompt,
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
const rewindSheet = page.getByTestId("rewind-menu-content");
await expect(rewindSheet).toBeVisible();
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
await expect(
rewindSheet.getByText("This action cannot be undone", { exact: true }),
).toBeVisible();
await page.getByTestId("rewind-menu-conversation").click();
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
await page.getByText(firstPrompt, { exact: true }).hover();
await page.getByTestId("rewind-menu-trigger").first().click();
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -0,0 +1,92 @@
import { expect, test, type Page } from "./fixtures";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { connectTerminalClient } from "./helpers/terminal-perf";
import { createTempGitRepo } from "./helpers/workspace";
import {
composerLocator,
expectComposerEditable,
expectComposerVisible,
fillComposerDraft,
submitMessage,
} from "./helpers/composer";
function getServerId(): string {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
return serverId;
}
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
const agentUrl = `${buildHostWorkspaceRoute(
getServerId(),
input.cwd,
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
await page.goto(agentUrl);
await page.waitForURL(
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
{ timeout: 60_000 },
);
await expectComposerVisible(page);
}
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
}
async function expectIdleComposer(page: Page): Promise<void> {
await expectComposerEditable(page);
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 15_000,
});
}
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
await expectIdleComposer(page);
await page.waitForTimeout(1_000);
await expectIdleComposer(page);
}
test.describe("User message UI contract", () => {
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
const repo = await createTempGitRepo("user-message-contract-e2e-");
const client = await connectTerminalClient();
const prompts = [
"emit 1 coalesced agent stream updates for user message contract turn one.",
"emit 1 coalesced agent stream updates for user message contract turn two.",
"emit 1 coalesced agent stream updates for user message contract turn three.",
];
try {
await client.openProject(repo.path);
const agent = await client.createAgent({
provider: "mock",
cwd: repo.path,
title: "User message contract e2e",
modeId: "load-test",
model: "ten-second-stream",
});
await openAgent(page, { cwd: repo.path, agentId: agent.id });
for (let index = 0; index < prompts.length; index += 1) {
const prompt = prompts[index]!;
await submitMessage(page, prompt);
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
timeout: 15_000,
});
await expectUserMessageCount(page, index + 1);
await expectNoLoadingRegressionAfterIdle(page);
}
await fillComposerDraft(page, "append");
await composerLocator(page).evaluate((element) => element.blur());
await expectUserMessageCount(page, 3);
await expectIdleComposer(page);
} finally {
await client.close();
await repo.cleanup();
}
});
});
+2 -1
View File
@@ -21,7 +21,8 @@
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:browser": "vitest run --project browser",
"test:e2e": "playwright test",
"test:e2e": "playwright test --project='Desktop Chrome'",
"test:e2e:real": "playwright test --project=real-provider",
"test:e2e:ui": "playwright test --ui",
"build": "npm run build:web",
"build:web": "npm run build:workspace-deps && expo export --platform web",
+6
View File
@@ -27,6 +27,12 @@ export default defineConfig({
projects: [
{
name: "Desktop Chrome",
testIgnore: ["**/*.real.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
{
name: "real-provider",
testMatch: ["**/*.real.spec.ts"],
use: { ...devices["Desktop Chrome"] },
},
],
+6 -1
View File
@@ -412,16 +412,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
return (
<UserMessage
serverId={resolvedServerId}
agentId={agentId}
messageId={item.id}
message={item.text}
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
capabilities={agent.capabilities}
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
/>
);
},
[],
[agent.capabilities, agentId, client, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -353,6 +353,7 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.images).toEqual([image]);
expect(userMessage.attachments).toEqual(call.options.attachments);
expect(userMessage.id).toBe(call.options.messageId);
expect(userMessage.optimistic).toBe(true);
});
it("appends to the existing head when one is present", async () => {
@@ -135,6 +135,7 @@ export async function dispatchComposerAgentMessage(
id: messageId,
text: input.text,
timestamp: new Date(),
optimistic: true,
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
};
+29 -1
View File
@@ -102,15 +102,23 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
export type { InlinePathTarget } from "@/assistant-file-links";
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
interface UserMessageProps {
serverId?: string;
agentId?: string;
messageId?: string;
message: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
timestamp: number;
capabilities?: AgentCapabilityFlags;
client?: DaemonClient | null;
isFirstInGroup?: boolean;
isLastInGroup?: boolean;
disableOuterSpacing?: boolean;
@@ -450,10 +458,15 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
}
export const UserMessage = memo(function UserMessage({
serverId,
agentId,
messageId,
message,
images = [],
attachments = [],
timestamp,
capabilities,
client,
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
@@ -469,10 +482,17 @@ export const UserMessage = memo(function UserMessage({
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
);
const rewindMutation = useRewindAgentMutation({ serverId, agentId, client, messageId });
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const getMessageContent = useCallback(() => message, [message]);
const handleRewind = useCallback(
(input: { mode: RewindMode; rewoundText: string }) => {
return rewindMutation.rewindAgent(input);
},
[rewindMutation],
);
const containerStyle = useMemo(
() => [
@@ -510,7 +530,7 @@ export const UserMessage = memo(function UserMessage({
);
return (
<View style={containerStyle}>
<View style={containerStyle} testID="user-message">
<View
style={userMessageStylesheet.content}
onPointerEnter={handlePointerEnter}
@@ -549,6 +569,14 @@ export const UserMessage = memo(function UserMessage({
{hasText ? (
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
{capabilities ? (
<RewindMenu
capabilities={capabilities}
isPending={rewindMutation.isPending}
rewoundText={message}
onRewind={handleRewind}
/>
) : null}
<TurnCopyButton
getContent={getMessageContent}
containerStyle={userMessageStylesheet.copyButton}
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { restoreComposerTextIfEmpty } from "./composer-restore";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
describe("restoreComposerTextIfEmpty", () => {
test("restores the rewound message when the composer is empty", () => {
expect(
restoreComposerTextIfEmpty({
currentText: "",
rewoundText: "message before rewind",
}),
).toBe("message before rewind");
});
test("preserves an existing composer draft", () => {
expect(
restoreComposerTextIfEmpty({
currentText: "keep this draft",
rewoundText: "message before rewind",
}),
).toBe("keep this draft");
});
});
describe("shouldRestoreComposerForRewindMode", () => {
test("restores only conversation-mutating rewind modes", () => {
expect(shouldRestoreComposerForRewindMode("conversation")).toBe(true);
expect(shouldRestoreComposerForRewindMode("files")).toBe(false);
expect(shouldRestoreComposerForRewindMode("both")).toBe(true);
});
});
@@ -0,0 +1,68 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ReactNode,
} from "react";
interface RewindComposerRestoreContextValue {
restoreTextIfComposerEmpty: (text: string) => void;
}
interface RewindComposerRestoreProviderProps {
text: string;
setText: (text: string) => void;
children: ReactNode;
}
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
export function restoreComposerTextIfEmpty(input: {
currentText: string;
rewoundText: string;
}): string {
if (input.currentText.length > 0) {
return input.currentText;
}
return input.rewoundText;
}
export function RewindComposerRestoreProvider({
text,
setText,
children,
}: RewindComposerRestoreProviderProps) {
const textRef = useRef(text);
useEffect(() => {
textRef.current = text;
}, [text]);
const restoreTextIfComposerEmpty = useCallback(
(rewoundText: string) => {
const nextText = restoreComposerTextIfEmpty({
currentText: textRef.current,
rewoundText,
});
if (nextText !== textRef.current) {
setText(nextText);
}
},
[setText],
);
const value = useMemo(() => ({ restoreTextIfComposerEmpty }), [restoreTextIfComposerEmpty]);
return (
<RewindComposerRestoreContext.Provider value={value}>
{children}
</RewindComposerRestoreContext.Provider>
);
}
export function useRewindComposerRestore(): RewindComposerRestoreContextValue | null {
return useContext(RewindComposerRestoreContext);
}
@@ -0,0 +1,164 @@
import { memo, useCallback, useMemo, useState, type ComponentType } from "react";
import { Text, View } from "react-native";
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
type RewindMenuItem,
type RewindMode,
useRewindCapabilities,
} from "./use-rewind-capabilities";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
export type { RewindMode };
interface RewindMenuProps {
capabilities: AgentCapabilityFlags;
rewoundText: string;
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
isPending?: boolean;
testID?: string;
}
const REWIND_HEADER: SheetHeader = { title: "Rewind to this message" };
const SNAP_POINTS: string[] = ["42%", "72%"];
function iconForItem(item: RewindMenuItem): ComponentType<{ color: string; size: number }> {
switch (item.mode) {
case "conversation":
return MessageSquare;
case "files":
return FileText;
case "both":
return Layers;
}
}
export const RewindMenu = memo(function RewindMenu({
capabilities,
rewoundText,
onRewind,
isPending = false,
testID = "rewind-menu",
}: RewindMenuProps) {
const items = useRewindCapabilities(capabilities);
const [isSheetVisible, setIsSheetVisible] = useState(false);
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
const handleOpen = useCallback(() => setIsSheetVisible(true), []);
const handleClose = useCallback(() => {
if (!isPending) {
setIsSheetVisible(false);
}
}, [isPending]);
const handleSelect = useCallback(
(mode: RewindMode) => async () => {
if (isPending) {
return;
}
setPendingMode(mode);
try {
await onRewind({ mode, rewoundText });
} catch {
// useRewindAgentMutation owns the toast; the sheet only owns flow state.
} finally {
setPendingMode(null);
setIsSheetVisible(false);
}
},
[isPending, onRewind, rewoundText],
);
const triggerIcon = useCallback((color: string) => <Undo2 size={16} color={color} />, []);
const tooltipContent = useMemo(
() => (
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Rewind to this message</Text>
</TooltipContent>
),
[],
);
if (items.length === 0) {
return null;
}
return (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Button
accessibilityLabel="Rewind to this message"
disabled={isPending}
leftIcon={triggerIcon}
onPress={handleOpen}
size="xs"
style={styles.trigger}
testID={`${testID}-trigger`}
variant="ghost"
/>
</TooltipTrigger>
{tooltipContent}
</Tooltip>
<AdaptiveModalSheet
desktopMaxWidth={420}
header={REWIND_HEADER}
onClose={handleClose}
scrollable={false}
snapPoints={SNAP_POINTS}
testID={`${testID}-content`}
visible={isSheetVisible}
>
<View style={styles.content}>
<Text style={styles.warningText}>This action cannot be undone</Text>
<View style={styles.actions}>
{items.map((item) => (
<Button
key={item.mode}
leftIcon={iconForItem(item)}
loading={pendingMode === item.mode}
disabled={isPending}
onPress={handleSelect(item.mode)}
size="md"
style={styles.actionButton}
testID={item.testID}
variant="secondary"
>
{item.label}
</Button>
))}
</View>
</View>
</AdaptiveModalSheet>
</>
);
});
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 24,
height: 24,
minHeight: 24,
paddingHorizontal: 0,
paddingVertical: 0,
alignItems: "center",
justifyContent: "center",
},
tooltipText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
},
content: {
gap: theme.spacing[4],
},
warningText: {
color: theme.colors.statusWarning,
fontSize: theme.fontSize.sm,
},
actions: {
gap: theme.spacing[2],
},
actionButton: {
justifyContent: "flex-start",
},
}));
@@ -0,0 +1,5 @@
import type { RewindMode } from "./use-rewind-capabilities";
export function shouldRestoreComposerForRewindMode(mode: RewindMode): boolean {
return mode === "conversation" || mode === "both";
}
@@ -0,0 +1,80 @@
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@server/client/daemon-client";
import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { clearOptimisticUserMessages } from "@/types/stream";
interface UseRewindAgentMutationInput {
serverId?: string;
agentId?: string;
messageId?: string;
client?: DaemonClient | null;
}
interface RewindAgentInput {
mode: RewindMode;
rewoundText: string;
}
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
rewindAgent: (input: RewindAgentInput) => Promise<void>;
isPending: boolean;
} {
const toast = useToast();
const composerRestore = useRewindComposerRestore();
const { isPending, mutateAsync } = useMutation({
mutationFn: async ({ mode }: RewindAgentInput) => {
if (!input.client || !input.agentId || !input.messageId) {
throw new Error("Daemon client not available");
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
if (input.serverId) {
const session = useSessionStore.getState().sessions[input.serverId];
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
});
}
const cursor = input.serverId
? useSessionStore
.getState()
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
: undefined;
await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail",
projection: "canonical",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
});
}
},
onSuccess: (_data, variables) => {
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
return;
}
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : "Failed to rewind agent");
},
});
const rewindAgent = useCallback(
async (rewindInput: RewindAgentInput) => {
if (isPending) {
return;
}
await mutateAsync(rewindInput);
},
[isPending, mutateAsync],
);
return {
rewindAgent,
isPending,
};
}
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import { resolveRewindMenuItems } from "./use-rewind-capabilities";
describe("resolveRewindMenuItems", () => {
test("returns no items when the provider declares no rewind capability", () => {
expect(
resolveRewindMenuItems({
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
}),
).toEqual([]);
});
test("returns only the capabilities declared by the provider", () => {
expect(
resolveRewindMenuItems({
supportsRewindConversation: true,
supportsRewindFiles: false,
supportsRewindBoth: true,
}),
).toEqual([
{
mode: "conversation",
label: "Rewind conversation",
testID: "rewind-menu-conversation",
},
{
mode: "both",
label: "Rewind conversation and files",
testID: "rewind-menu-both",
},
]);
});
});
@@ -0,0 +1,53 @@
import { useMemo } from "react";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
export type RewindMode = "conversation" | "files" | "both";
export interface RewindMenuItem {
mode: RewindMode;
label: string;
testID: string;
}
export function resolveRewindMenuItems(
capabilities:
| Pick<
AgentCapabilityFlags,
"supportsRewindConversation" | "supportsRewindFiles" | "supportsRewindBoth"
>
| null
| undefined,
): RewindMenuItem[] {
if (!capabilities) {
return [];
}
const items: RewindMenuItem[] = [];
if (capabilities.supportsRewindConversation) {
items.push({
mode: "conversation",
label: "Rewind conversation",
testID: "rewind-menu-conversation",
});
}
if (capabilities.supportsRewindFiles) {
items.push({
mode: "files",
label: "Rewind files",
testID: "rewind-menu-files",
});
}
if (capabilities.supportsRewindBoth) {
items.push({
mode: "both",
label: "Rewind conversation and files",
testID: "rewind-menu-both",
});
}
return items;
}
export function useRewindCapabilities(
capabilities: Parameters<typeof resolveRewindMenuItems>[0],
): RewindMenuItem[] {
return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]);
}
@@ -1664,6 +1664,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
id: messageId,
text: message,
timestamp: new Date(),
optimistic: true,
...(images && images.length > 0 ? { images } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}),
};
// Append to head if streaming (keeps the user message with the current
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
commandDraftConfig: DraftCommandConfig | undefined;
};
interface AgentInputDraft {
export interface AgentInputDraft {
text: string;
setText: (text: string) => void;
attachments: UserComposerAttachment[];
@@ -99,6 +99,16 @@ function expectSyncErrorSync(state: ReadyState): void {
expect(state.sync.status).toBe("sync_error");
}
function stateMachineLabel(state: AgentScreenViewState): "loading" | "resolving" | Agent["status"] {
if (state.tag === "boot") {
return state.reason;
}
if (state.tag === "ready") {
return state.agent.status;
}
return "error";
}
describe("deriveAgentScreenViewState", () => {
it("returns boot loading before first interactive paint", () => {
const memory = createBaseMemory();
@@ -383,6 +393,39 @@ describe("deriveAgentScreenViewState", () => {
expect(ready.agent.status).toBe("running");
});
it("keeps send lifecycle transitions forward-only across optimistic and authoritative handoff", () => {
let memory = createBaseMemory();
const transitions: Array<"loading" | "resolving" | Agent["status"]> = [];
for (const input of [
createBaseInput(),
{
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
placeholderAgent: createAgent("agent-1"),
shouldUseOptimisticStream: true,
},
{
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "running" }),
placeholderAgent: createAgent("agent-1"),
shouldUseOptimisticStream: true,
},
{
...createBaseInput(),
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
},
] satisfies AgentScreenMachineInput[]) {
const result = deriveAgentScreenViewState({ input, memory });
memory = result.memory;
transitions.push(stateMachineLabel(result.state));
}
expect(transitions).toEqual(["loading", "running", "running", "idle"]);
expect(transitions.join(" -> ")).not.toContain("running -> loading");
expect(transitions.join(" -> ")).not.toContain("loading -> idle");
});
it("keeps optimistic running status while authoritative agent is initializing", () => {
const memory = createBaseMemory();
const input: AgentScreenMachineInput = {
@@ -1,10 +1,12 @@
import { useRef } from "react";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
export interface AgentScreenAgent {
serverId: string;
id: string;
status: "initializing" | "idle" | "running" | "error" | "closed";
cwd: string;
capabilities?: AgentCapabilityFlags;
lastError?: string | null;
projectPlacement?: {
checkout?: {
@@ -133,6 +133,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
id: machine.attempt.clientMessageId,
text: machine.attempt.text,
timestamp: machine.attempt.timestamp,
optimistic: true,
...(machine.attempt.images && machine.attempt.images.length > 0
? { images: machine.attempt.images }
: {}),
+159 -63
View File
@@ -11,6 +11,7 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { Composer } from "@/components/composer";
import { FileDropZone } from "@/components/file-drop-zone";
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
import type { ImageAttachment } from "@/components/message-input";
import { getProviderIcon } from "@/components/provider-icons";
import { ToastViewport, useToastHost } from "@/components/toast-host";
@@ -23,7 +24,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative, isWeb } from "@/constants/platform";
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
import { useAgentInputDraft, type AgentInputDraft } from "@/hooks/use-agent-input-draft";
import {
type AgentScreenAgent,
type AgentScreenMissingState,
@@ -69,6 +70,7 @@ interface ChatAgentStateShape {
id: string | null;
status: Agent["status"] | null;
cwd: string | null;
capabilities?: Agent["capabilities"];
lastError?: Agent["lastError"] | null;
}
@@ -111,6 +113,7 @@ function selectChatAgentState(
id: agent.id,
status: agent.status,
cwd: agent.cwd,
capabilities: agent.capabilities,
lastError: agent.lastError ?? null,
archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false,
@@ -130,6 +133,7 @@ function buildChatAgentFromState(
id: state.id,
status: state.status,
cwd: state.cwd,
capabilities: state.capabilities,
lastError: state.lastError ?? null,
projectPlacement,
};
@@ -945,70 +949,162 @@ function ChatAgentContent({
effectiveAgent,
});
if (nonReadyView) return nonReadyView;
invariant(agentId, "agent id is defined when agent content is ready");
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
invariant(agentState.cwd, "agent cwd is defined when agent content is ready");
const agentCwd = agentState.cwd;
invariant(agentCwd, "agent cwd is defined when agent content is ready");
const showHistorySyncOverlay =
viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay";
return (
<View style={styles.root}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={animatedContentStyle}>
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
pendingCreate={pendingCreate}
shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
<ChatAgentReadyContent
serverId={serverId}
agentId={agentId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
agentState={agentState}
effectiveAgent={effectiveAgent}
pendingCreate={pendingCreate}
shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
panelToast={panelToast}
streamViewRef={streamViewRef}
animatedContentStyle={animatedContentStyle}
handleFilesDropped={handleFilesDropped}
handleAddImagesCallback={handleAddImagesCallback}
handleComposerHeightChange={handleComposerHeightChange}
handleMessageSent={handleMessageSent}
showPendingCreateSubmitLoading={showPendingCreateSubmitLoading}
showHistorySyncOverlay={showHistorySyncOverlay}
cwd={agentCwd}
attentionController={attentionController}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
}
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
cwd={agentState.cwd}
isSubmitLoading={showPendingCreateSubmitLoading}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
function ChatAgentReadyContent({
serverId,
agentId,
isPaneFocused,
isArchivingCurrentAgent,
agentState,
effectiveAgent,
pendingCreate,
shouldUseOptimisticStream,
canFinalizePendingCreate,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
panelToast,
streamViewRef,
animatedContentStyle,
handleFilesDropped,
handleAddImagesCallback,
handleComposerHeightChange,
handleMessageSent,
showPendingCreateSubmitLoading,
showHistorySyncOverlay,
cwd,
attentionController,
onOpenWorkspaceFile,
}: {
serverId: string;
agentId: string;
isPaneFocused: boolean;
isArchivingCurrentAgent: boolean;
agentState: ChatAgentSelectedState;
effectiveAgent: AgentScreenAgent;
pendingCreate: PendingCreateAttempt | null;
shouldUseOptimisticStream: boolean;
canFinalizePendingCreate: boolean;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
panelToast: ReturnType<typeof useToastHost>;
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
animatedContentStyle: object[];
handleFilesDropped: (files: ImageAttachment[]) => void;
handleAddImagesCallback: (addImages: (images: ImageAttachment[]) => void) => void;
handleComposerHeightChange: (height: number) => void;
handleMessageSent: () => void;
showPendingCreateSubmitLoading: boolean;
showHistorySyncOverlay: boolean;
cwd: string;
attentionController: ReturnType<typeof useAgentAttentionClear>;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId,
}),
});
{viewState.tag === "ready" &&
viewState.sync.status === "catching_up" &&
viewState.sync.ui === "overlay" ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
return (
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
<View style={styles.root}>
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
<View style={styles.container}>
<View style={styles.contentContainer}>
<ReanimatedAnimated.View style={animatedContentStyle}>
<AgentStreamSection
streamViewRef={streamViewRef}
serverId={serverId}
agentId={agentId}
agent={effectiveAgent}
pendingCreate={pendingCreate}
shouldUseOptimisticStream={shouldUseOptimisticStream}
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
</View>
) : null}
<ToastViewport
toast={panelToast.toast}
onDismiss={panelToast.dismiss}
placement="panel"
/>
</View>
</FileDropZone>
<AgentComposerSection
agentId={agentId}
serverId={serverId}
isPaneFocused={isPaneFocused}
isArchivingCurrentAgent={isArchivingCurrentAgent}
archivedAt={agentState.archivedAt}
cwd={cwd}
isSubmitLoading={showPendingCreateSubmitLoading}
agentInputDraft={agentInputDraft}
onAttentionInputFocus={attentionController.clearOnInputFocus}
onAttentionPromptSend={attentionController.clearOnPromptSend}
onAddImages={handleAddImagesCallback}
onComposerHeightChange={handleComposerHeightChange}
onMessageSent={handleMessageSent}
/>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View>
) : null}
</View>
{showHistorySyncOverlay ? (
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
</View>
) : null}
<ToastViewport
toast={panelToast.toast}
onDismiss={panelToast.dismiss}
placement="panel"
/>
</View>
</FileDropZone>
{isArchivingCurrentAgent ? (
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
<Text style={styles.archivingTitle}>Archiving agent...</Text>
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
</View>
) : null}
</View>
</RewindComposerRestoreProvider>
);
}
@@ -1081,6 +1177,7 @@ function AgentStreamSection({
id: pendingCreate.clientMessageId,
text: pendingCreate.text,
timestamp: new Date(pendingCreate.timestamp),
optimistic: true,
...(pendingCreate.images && pendingCreate.images.length > 0
? { images: pendingCreate.images }
: {}),
@@ -1183,6 +1280,7 @@ function AgentComposerSection({
archivedAt,
cwd,
isSubmitLoading,
agentInputDraft,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
@@ -1196,6 +1294,7 @@ function AgentComposerSection({
archivedAt: Date | null;
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
@@ -1219,6 +1318,7 @@ function AgentComposerSection({
isPaneFocused={isPaneFocused}
cwd={cwd}
isSubmitLoading={isSubmitLoading}
agentInputDraft={agentInputDraft}
onAttentionInputFocus={onAttentionInputFocus}
onAttentionPromptSend={onAttentionPromptSend}
onAddImages={onAddImages}
@@ -1234,6 +1334,7 @@ function ActiveAgentComposer({
isPaneFocused,
cwd,
isSubmitLoading,
agentInputDraft,
onAttentionInputFocus,
onAttentionPromptSend,
onAddImages,
@@ -1245,6 +1346,7 @@ function ActiveAgentComposer({
isPaneFocused: boolean;
cwd: string;
isSubmitLoading: boolean;
agentInputDraft: AgentInputDraft;
onAttentionInputFocus: () => void;
onAttentionPromptSend: () => void;
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
@@ -1270,12 +1372,6 @@ function ActiveAgentComposer({
[serverId],
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const agentInputDraft = useAgentInputDraft({
draftKey: buildDraftStoreKey({
serverId,
agentId,
}),
});
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
cwd,
@@ -276,8 +276,17 @@ describe("processTimelineResponse", () => {
});
it("sets cursor to null when reset=true but no cursors in payload", () => {
const existingTail: StreamItem[] = [
{
kind: "user_message",
id: "only-user",
text: "first turn",
timestamp: new Date(500),
},
];
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: existingTail,
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
payload: {
...baseTimelineInput.payload,
@@ -288,6 +297,8 @@ describe("processTimelineResponse", () => {
expect(result.cursor).toBe(null);
expect(result.cursorChanged).toBe(true);
expect(result.tail).toEqual([]);
expect(getUserTexts(result.tail)).toHaveLength(0);
});
it("performs bootstrap tail init with catch-up side effect", () => {
@@ -933,6 +944,34 @@ describe("processAgentStreamEvent", () => {
expect(result.sideEffects).toEqual([]);
});
it("resets visible timeline when a new epoch starts at seq 1", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const currentTail = [makeAssistantItem("old timeline")];
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("rewound start", "user_message"),
seq: 1,
epoch: "epoch-2",
currentCursor: existingCursor,
currentTail,
});
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-2",
startSeq: 1,
endSeq: 1,
});
expect(getAssistantTexts(result.tail)).toEqual([]);
expect(getUserTexts(result.tail)).toEqual(["rewound start"]);
expect(result.sideEffects).toEqual([]);
});
it("initializes cursor when none exists", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
@@ -623,6 +623,14 @@ export interface AgentStreamReducerEvent {
timestamp: Date;
}
interface TimelineSequencingGateResult {
shouldApplyStreamEvent: boolean;
nextTimelineCursor: TimelineCursor | null;
cursorChanged: boolean;
resetLiveTimeline: boolean;
sideEffects: AgentStreamReducerSideEffect[];
}
export interface AgentStreamReducerAgentSnapshot {
status: AgentLifecycleStatus;
updatedAt: Date;
@@ -672,61 +680,91 @@ function applyAgentPatch(
};
}
function processTimelineSequencingGate(input: {
event: AgentStreamEventPayload;
seq: number | undefined;
epoch: string | undefined;
currentCursor: TimelineCursor | undefined;
}): TimelineSequencingGateResult {
const { event, seq, epoch, currentCursor } = input;
const base: TimelineSequencingGateResult = {
shouldApplyStreamEvent: true,
nextTimelineCursor: null,
cursorChanged: false,
resetLiveTimeline: false,
sideEffects: [],
};
if (event.type !== "timeline" || typeof seq !== "number" || typeof epoch !== "string") {
return base;
}
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
epoch,
seq,
});
if (decision === "init") {
return {
...base,
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
cursorChanged: true,
};
}
if (decision === "accept") {
return {
...base,
nextTimelineCursor: {
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
epoch,
endSeq: seq,
},
cursorChanged: true,
};
}
if (decision === "gap") {
return {
...base,
shouldApplyStreamEvent: false,
sideEffects: currentCursor
? [
{
type: "catch_up",
cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
},
]
: [],
};
}
if (decision === "drop_epoch" && seq === 1) {
return {
...base,
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
cursorChanged: true,
resetLiveTimeline: true,
};
}
return {
...base,
shouldApplyStreamEvent: false,
};
}
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
input;
let shouldApplyStreamEvent = true;
let nextTimelineCursor: TimelineCursor | null = null;
let cursorChanged = false;
const sideEffects: AgentStreamReducerSideEffect[] = [];
// ------------------------------------------------------------------
// Timeline sequencing gate
// ------------------------------------------------------------------
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
epoch,
seq,
});
if (decision === "init") {
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
cursorChanged = true;
} else if (decision === "accept") {
nextTimelineCursor = {
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
epoch,
endSeq: seq,
};
cursorChanged = true;
} else if (decision === "gap") {
shouldApplyStreamEvent = false;
if (currentCursor) {
sideEffects.push({
type: "catch_up",
cursor: {
epoch: currentCursor.epoch,
endSeq: currentCursor.endSeq,
},
});
}
} else {
// drop_stale or drop_epoch
shouldApplyStreamEvent = false;
}
}
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
? applyStreamEvent({
tail: currentTail,
head: currentHead,
tail: sequencing.resetLiveTimeline ? [] : currentTail,
head: sequencing.resetLiveTimeline ? [] : currentHead,
event,
timestamp,
source: "live",
@@ -771,11 +809,11 @@ export function processAgentStreamEvent(
head,
changedTail,
changedHead,
cursor: nextTimelineCursor,
cursorChanged,
cursor: sequencing.nextTimelineCursor,
cursorChanged: sequencing.cursorChanged,
agent: agentPatch,
agentChanged,
sideEffects,
sideEffects: sequencing.sideEffects,
};
}
+271 -2
View File
@@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import invariant from "tiny-invariant";
import { describe, it } from "vitest";
import { describe, expect, it } from "vitest";
import {
applyStreamEvent,
clearOptimisticUserMessages,
hydrateStreamState,
mergeToolCallDetail,
reduceStreamUpdate,
@@ -309,7 +311,7 @@ describe("stream reducer canonical tool calls", () => {
const first = hydrateStreamState(updates);
const second = hydrateStreamState(updates);
assert.strictEqual(JSON.stringify(first), JSON.stringify(second));
expect(first).toEqual(second);
const assistantMessage = first.find((item) => item.kind === "assistant_message");
assert.strictEqual(assistantMessage?.text, "Hello world");
});
@@ -735,6 +737,7 @@ describe("stream reducer canonical tool calls", () => {
id: messageId,
text: "Analyze this image",
timestamp: new Date("2025-01-01T11:10:00Z"),
optimistic: true,
images: optimisticImages,
},
];
@@ -935,4 +938,270 @@ describe("turn lifecycle events", () => {
["user_message", "user_message"],
);
});
it.each(["codex", "opencode", "pi"] satisfies AgentProvider[])(
"replaces an optimistic user message when a live %s provider-owned id echo arrives without text matching",
(provider) => {
const optimisticTimestamp = new Date("2025-01-01T15:02:00Z");
const serverTimestamp = new Date("2025-01-01T15:02:01Z");
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic",
text: "same user text",
timestamp: optimisticTimestamp,
optimistic: true,
images: [
{
id: "image-1",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "image-1",
createdAt: optimisticTimestamp.getTime(),
},
],
attachments: [
{
type: "text",
mimeType: "text/plain",
text: "attached context",
title: "context.txt",
},
],
};
const state = reduceStreamUpdate(
[optimistic],
{
type: "timeline",
provider,
item: {
type: "user_message",
text: "server-owned rendered text",
messageId: "provider-owned-id",
},
},
serverTimestamp,
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "provider-owned-id");
assert.strictEqual(userMessage.text, "server-owned rendered text");
assert.deepStrictEqual(userMessage.images, optimistic.images);
assert.deepStrictEqual(userMessage.attachments, optimistic.attachments);
},
);
it("replaces one optimistic plain-text user message with the next live server user message", () => {
const optimisticTimestamp = new Date("2025-01-01T15:03:00Z");
const serverTimestamp = new Date("2025-01-01T15:03:01Z");
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic",
text: "typed plain text",
timestamp: optimisticTimestamp,
optimistic: true,
};
const state = reduceStreamUpdate(
[optimistic],
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "typed plain text",
messageId: "msg_opencode_provider_owned",
},
},
serverTimestamp,
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
const userMessage = userMessages[0];
invariant(userMessage?.kind === "user_message");
assert.strictEqual(userMessage.id, "msg_opencode_provider_owned");
assert.strictEqual(userMessage.text, "typed plain text");
assert.strictEqual(userMessage.optimistic, undefined);
});
it("reconciles an optimistic user message that was pending in the streaming head", () => {
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_head_optimistic",
text: "plain text in head",
timestamp: new Date("2025-01-01T15:03:02Z"),
optimistic: true,
};
const result = applyStreamEvent({
tail: [],
head: [optimistic],
event: {
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "plain text in head",
messageId: "provider-owned-head",
},
},
timestamp: new Date("2025-01-01T15:03:03Z"),
source: "live",
});
assert.strictEqual(result.head.length, 0);
const userMessages = result.tail.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-head");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("replaces multiple optimistic user messages in FIFO order", () => {
const optimisticTimestamp = new Date("2025-01-01T15:04:00Z");
const serverTimestamp = new Date("2025-01-01T15:04:01Z");
const firstOptimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic_1",
text: "first typed text",
timestamp: optimisticTimestamp,
optimistic: true,
};
const secondOptimistic: StreamItem = {
kind: "user_message",
id: "msg_optimistic_2",
text: "second typed text",
timestamp: new Date("2025-01-01T15:04:00.500Z"),
optimistic: true,
};
const afterFirstEcho = reduceStreamUpdate(
[firstOptimistic, secondOptimistic],
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "first server text",
messageId: "provider-owned-first",
},
},
serverTimestamp,
{ source: "live" },
);
const state = reduceStreamUpdate(
afterFirstEcho,
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "second server text",
messageId: "provider-owned-second",
},
},
new Date("2025-01-01T15:04:02Z"),
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 2);
assert.deepStrictEqual(
userMessages.map((item) => [item.id, item.text, item.optimistic]),
[
["provider-owned-first", "first server text", undefined],
["provider-owned-second", "second server text", undefined],
],
);
});
it("appends a live server user message when no optimistic user message is pending", () => {
const state = reduceStreamUpdate(
[],
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "resumed session text",
messageId: "provider-owned-resume",
},
},
new Date("2025-01-01T15:04:03Z"),
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-resume");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("does not match a server user message to an optimistic from a rewound turn after pending optimistics are cleared", () => {
const optimistic: StreamItem = {
kind: "user_message",
id: "msg_rewound_optimistic",
text: "rewound text",
timestamp: new Date("2025-01-01T15:04:04Z"),
optimistic: true,
};
const cleared = clearOptimisticUserMessages([optimistic]);
const state = reduceStreamUpdate(
cleared,
{
type: "timeline",
provider: "opencode",
item: {
type: "user_message",
text: "future server echo",
messageId: "provider-owned-after-rewind",
},
},
new Date("2025-01-01T15:04:05Z"),
{ source: "live" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 1);
assert.strictEqual(userMessages[0]?.id, "provider-owned-after-rewind");
assert.strictEqual(userMessages[0]?.text, "future server echo");
assert.strictEqual(userMessages[0]?.optimistic, undefined);
});
it("keeps canonical repeated user messages distinct during hydration", () => {
const state = hydrateStreamState(
[
{
event: {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: "repeat", messageId: "native-1" },
},
timestamp: new Date("2025-01-01T15:03:00Z"),
},
{
event: {
type: "timeline",
provider: "codex",
item: { type: "user_message", text: "repeat", messageId: "native-2" },
},
timestamp: new Date("2025-01-01T15:03:01Z"),
},
],
{ source: "canonical" },
);
const userMessages = state.filter((item) => item.kind === "user_message");
assert.strictEqual(userMessages.length, 2);
assert.deepStrictEqual(
userMessages.map((item) => item.id),
["native-1", "native-2"],
);
});
});
+41 -15
View File
@@ -59,6 +59,7 @@ export interface UserMessageItem {
id: string;
text: string;
timestamp: Date;
optimistic?: true;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
}
@@ -184,10 +185,32 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem {
};
}
function buildUserMessageItem(input: {
id: string;
text: string;
timestamp: Date;
optimistic?: UserMessageItem | null;
}): UserMessageItem {
const preservedImages = input.optimistic?.images;
const preservedAttachments = input.optimistic?.attachments;
return {
kind: "user_message",
id: input.id,
text: input.text,
timestamp: input.timestamp,
...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}),
...(preservedAttachments && preservedAttachments.length > 0
? { attachments: preservedAttachments }
: {}),
};
}
function appendUserMessage(
state: StreamItem[],
text: string,
timestamp: Date,
source: StreamUpdateSource,
messageId?: string,
): StreamItem[] {
const { chunk, hasContent } = normalizeChunk(text);
@@ -197,32 +220,33 @@ function appendUserMessage(
const chunkSeed = chunk.trim() || chunk;
const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
const existingIndex = state.findIndex(
(entry) => entry.kind === "user_message" && entry.id === entryId,
);
const existing =
existingIndex >= 0 && state[existingIndex]?.kind === "user_message"
? state[existingIndex]
: null;
const preservedImages = existing?.images;
const optimisticIndex =
source === "live"
? state.findIndex((entry) => entry.kind === "user_message" && entry.optimistic)
: -1;
const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null;
const nextItem: UserMessageItem = {
kind: "user_message",
const nextItem = buildUserMessageItem({
id: entryId,
text: chunk,
timestamp,
...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}),
};
optimistic,
});
if (existingIndex >= 0) {
if (optimisticIndex >= 0) {
const next = [...state];
next[existingIndex] = nextItem;
next[optimisticIndex] = nextItem;
return next;
}
return [...state, nextItem];
}
export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] {
const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic);
return next.length === state.length ? state : next;
}
function appendAssistantMessage(
state: StreamItem[],
text: string,
@@ -664,7 +688,9 @@ function reduceTimelineEvent(
const item = event.item;
switch (item.type) {
case "user_message":
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
return finalizeActiveThoughts(
appendUserMessage(state, item.text, timestamp, source, item.messageId),
);
case "assistant_message":
return finalizeActiveThoughts(
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
@@ -64,6 +64,7 @@ import type {
ProviderDiagnosticResponseMessage,
DaemonGetStatusResponse,
DaemonGetPairingOfferResponse,
AgentRewindResponseMessage,
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
@@ -2232,6 +2233,40 @@ export class DaemonClient {
await this.sendAgentMessage(agentId, text, options);
}
async rewindAgent(
agentId: string,
messageId: string,
mode: "conversation" | "files" | "both",
): Promise<AgentRewindResponseMessage["payload"]> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "agent.rewind.request",
requestId,
agentId,
messageId,
mode,
});
const payload = await this.sendRequest({
requestId,
message,
timeout: 15000,
options: { skipQueue: true },
select: (msg) => {
if (msg.type !== "agent.rewind.response") {
return null;
}
if (msg.payload.requestId !== requestId) {
return null;
}
return msg.payload;
},
});
if (!payload.ok) {
throw new Error(payload.error ?? "Agent rewind failed");
}
return payload;
}
async cancelAgent(agentId: string): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
@@ -2192,9 +2192,10 @@ test("getAgent does not expose committed history internals once manager owns the
cwd: workdir,
});
manager.recordUserMessage(snapshot.id, "hello boundary", {
await manager.appendTimelineItem(snapshot.id, {
type: "user_message",
text: "hello boundary",
messageId: "msg-boundary-1",
emitState: false,
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
@@ -3442,7 +3443,7 @@ test("keeps updatedAt monotonic when user message and run start happen in the sa
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
try {
manager.recordUserMessage(snapshot.id, "hello");
await manager.appendTimelineItem(snapshot.id, { type: "user_message", text: "hello" });
const afterMessage = manager.getAgent(snapshot.id);
expect(afterMessage).toBeDefined();
const messageUpdatedAt = afterMessage!.updatedAt.getTime();
@@ -3464,38 +3465,6 @@ test("keeps updatedAt monotonic when user message and run start happen in the sa
}
});
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000121",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const lifecycleUpdates: string[] = [];
manager.subscribe((event) => {
if (event.type !== "agent_state" || event.agent.id !== snapshot.id) {
return;
}
lifecycleUpdates.push(event.agent.lifecycle);
});
lifecycleUpdates.length = 0;
manager.recordUserMessage(snapshot.id, "hello", { emitState: false });
expect(lifecycleUpdates).toEqual([]);
});
test("runAgent assembles finalText from trailing assistant chunks", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -5088,99 +5057,6 @@ test("closeAgent persists one final closed snapshot", async () => {
}
});
test("hydrateTimeline skips provider user_message items to prevent duplicates with recordUserMessage", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-dedup-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
// Session whose streamHistory yields user_message + assistant_message items.
// This simulates Codex provider replaying its thread history on resume.
class HistoryWithUserMessagesSession extends TestAgentSession {
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: this.provider,
item: { type: "user_message", text: "hello from user", messageId: "msg_client_1" },
};
yield {
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: "hi there" },
};
yield {
type: "timeline",
provider: this.provider,
item: { type: "user_message", text: "second question", messageId: "msg_client_2" },
};
yield {
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: "second answer" },
};
}
}
class HistoryUserMessageClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
// Fresh session yields history with user messages (simulates Codex resume)
return new HistoryWithUserMessagesSession(config);
}
async resumeSession(): Promise<AgentSession> {
throw new Error("Not used in this test");
}
}
const manager = new AgentManager({
clients: {
codex: new HistoryUserMessageClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000200",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
// Simulate canonical user messages already recorded by recordUserMessage
// (the path that session.ts takes when user sends a message)
manager.recordUserMessage(snapshot.id, "hello from user", {
messageId: "msg_client_1",
});
manager.recordUserMessage(snapshot.id, "second question", {
messageId: "msg_client_2",
});
const beforeHydrate = manager.getTimeline(snapshot.id);
const userMessagesBefore = beforeHydrate.filter((item) => item.type === "user_message");
expect(userMessagesBefore).toHaveLength(2);
// hydrateTimeline replays provider history which includes user_message
// items. These should NOT create duplicate rows since recordUserMessage
// already created canonical entries.
await manager.hydrateTimelineFromProvider(snapshot.id);
const afterHydrate = manager.getTimeline(snapshot.id);
const userMessagesAfter = afterHydrate.filter((item) => item.type === "user_message");
// Should still have exactly 2 user messages, not 4
expect(userMessagesAfter).toHaveLength(2);
// Non-user_message items from history should still be replayed
const assistantMessages = afterHydrate.filter((item) => item.type === "assistant_message");
expect(assistantMessages).toHaveLength(2);
});
test("hydrateTimeline keeps provider user_message items when no canonical user history exists", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-keep-user-"));
const storagePath = join(workdir, "agents");
@@ -5314,378 +5190,7 @@ test("hydrateTimeline preserves provider replay timestamps and marks missing one
expect(timeline[1]?.timestamp).toEqual(expect.any(String));
});
test("hydrateTimeline suppresses only matching canonical user_message messageId", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-partial-dedup-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class HistoryWithMixedUserMessagesSession extends TestAgentSession {
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "timeline",
provider: this.provider,
item: {
type: "user_message",
text: "hello from user",
messageId: "msg_client_hello",
},
};
yield {
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: "hi there" },
};
yield {
type: "timeline",
provider: this.provider,
item: {
type: "user_message",
text: "hello from user",
messageId: "msg_provider_distinct",
},
};
}
}
class HistoryMixedClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new HistoryWithMixedUserMessagesSession(config);
}
async resumeSession(): Promise<AgentSession> {
throw new Error("Not used in this test");
}
}
const manager = new AgentManager({
clients: { codex: new HistoryMixedClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000204",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
// Canonical user message that should dedupe the matching history item.
manager.recordUserMessage(snapshot.id, "hello from user", {
messageId: "msg_client_hello",
});
await manager.hydrateTimelineFromProvider(snapshot.id);
const timeline = manager.getTimeline(snapshot.id);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(2);
expect(userMessages.map((item) => item.messageId)).toEqual([
"msg_client_hello",
"msg_provider_distinct",
]);
expect(userMessages.map((item) => item.text)).toEqual(["hello from user", "hello from user"]);
});
test("recordUserMessage normalizes blank/whitespace messageId to undefined", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-blank-msgid-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000201",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
// Empty string should be treated as missing
manager.recordUserMessage(snapshot.id, "test empty", {
messageId: "",
});
// Whitespace-only should be treated as missing
manager.recordUserMessage(snapshot.id, "test whitespace", {
messageId: " ",
});
// Valid messageId should be preserved
manager.recordUserMessage(snapshot.id, "test valid", {
messageId: "msg_valid_123",
});
const timeline = manager.getTimeline(snapshot.id);
const userMessages = timeline.filter(
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
item.type === "user_message",
);
expect(userMessages).toHaveLength(3);
// Empty string → undefined (not empty string)
expect(userMessages[0].messageId).toBeUndefined();
// Whitespace → undefined
expect(userMessages[1].messageId).toBeUndefined();
// Valid → preserved
expect(userMessages[2].messageId).toBe("msg_valid_123");
});
test("recordUserMessage preserves provided messageId in timeline item and dispatched event", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-msgid-passthrough-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000202",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const events: AgentStreamEvent[] = [];
manager.subscribe((event) => {
if (event.type === "agent_stream") {
events.push(event.event);
}
});
const clientMsgId = "msg_abc_123_def";
manager.recordUserMessage(snapshot.id, "hello", {
messageId: clientMsgId,
});
// Timeline item should have the messageId
const timeline = manager.getTimeline(snapshot.id);
const userMsg = timeline.find(
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
item.type === "user_message",
);
expect(userMsg).toBeDefined();
expect(userMsg!.messageId).toBe(clientMsgId);
// Dispatched stream event should also carry the messageId
const streamEvent = events.find((e) => e.type === "timeline" && e.item.type === "user_message");
expect(streamEvent).toBeDefined();
if (streamEvent?.type === "timeline") {
expect((streamEvent.item as { type: "user_message"; messageId?: string }).messageId).toBe(
clientMsgId,
);
}
});
test("live provider user_message echo is suppressed when recordUserMessage was called first", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-echo-dedup-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
// Session whose live turn echoes the user message (as Claude does)
class EchoUserMessageSession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
const turnId = "turn-echo-1";
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
// Provider echoes user message during live run
this.pushEvent({
type: "timeline",
provider: this.provider,
item: {
type: "user_message",
text: "hello from user",
messageId: "msg_client_echo_1",
},
turnId,
});
this.pushEvent({
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: "hello from assistant" },
turnId,
});
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}, 0);
return { turnId };
}
}
class EchoClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new EchoUserMessageSession(config);
}
async resumeSession(): Promise<AgentSession> {
throw new Error("unused");
}
}
const manager = new AgentManager({
clients: { codex: new EchoClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000400",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
// Canonical recording (what session.ts does before starting stream)
manager.recordUserMessage(snapshot.id, "hello from user", {
messageId: "msg_client_echo_1",
});
// Run triggers startTurn(), which echoes user_message
await manager.runAgent(snapshot.id, { text: "hello from user" });
const timeline = manager.getTimeline(snapshot.id);
const userMessages = timeline.filter((item) => item.type === "user_message");
// Should be exactly 1 (canonical), not 2 (canonical + provider echo)
expect(userMessages).toHaveLength(1);
// The canonical one must carry the client messageId for optimistic matching
expect(userMessages[0].messageId).toBe("msg_client_echo_1");
// Assistant messages from the run should still appear
const assistantMessages = timeline.filter((item) => item.type === "assistant_message");
expect(assistantMessages).toHaveLength(1);
});
test("live provider user_message with different messageId is NOT suppressed", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-different-msgid-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class DifferentMessageIdSession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
const turnId = "turn-diff-msgid-1";
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({
type: "timeline",
provider: this.provider,
item: {
type: "user_message",
text: "hello from user",
messageId: "msg_provider_other",
},
turnId,
});
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}, 0);
return { turnId };
}
}
class DifferentMessageIdClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new DifferentMessageIdSession(config);
}
async resumeSession(): Promise<AgentSession> {
throw new Error("unused");
}
}
const manager = new AgentManager({
clients: { codex: new DifferentMessageIdClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000402",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
manager.recordUserMessage(snapshot.id, "hello from user", {
messageId: "msg_client_echo_2",
});
await manager.runAgent(snapshot.id, { text: "hello from user" });
const timeline = manager.getTimeline(snapshot.id);
const userMessages = timeline.filter(
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
item.type === "user_message",
);
expect(userMessages).toHaveLength(2);
expect(userMessages.map((item) => item.messageId)).toEqual([
"msg_client_echo_2",
"msg_provider_other",
]);
});
test("live provider user_message without messageId is NOT suppressed", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-no-msgid-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class NoMessageIdSession extends TestAgentSession {
override async startTurn(): Promise<{ turnId: string }> {
const turnId = "turn-no-msgid-1";
setTimeout(() => {
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
this.pushEvent({
type: "timeline",
provider: this.provider,
item: { type: "user_message", text: "hello from user" },
turnId,
});
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
}, 0);
return { turnId };
}
}
class NoMessageIdClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
async isAvailable(): Promise<boolean> {
return true;
}
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new NoMessageIdSession(config);
}
async resumeSession(): Promise<AgentSession> {
throw new Error("unused");
}
}
const manager = new AgentManager({
clients: { codex: new NoMessageIdClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000403",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
manager.recordUserMessage(snapshot.id, "hello from user", {
messageId: "msg_client_echo_3",
});
await manager.runAgent(snapshot.id, { text: "hello from user" });
const timeline = manager.getTimeline(snapshot.id);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(2);
});
test("provider user_message is NOT suppressed when no prior recordUserMessage", async () => {
test("provider user_message is recorded from the live stream", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-prior-record-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -5738,7 +5243,6 @@ test("provider user_message is NOT suppressed when no prior recordUserMessage",
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
// No recordUserMessage — run directly
await manager.runAgent(snapshot.id, { text: "do something" });
const timeline = manager.getTimeline(snapshot.id);
+92 -120
View File
@@ -55,6 +55,7 @@ import {
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
import { getAgentProviderDefinition } from "./provider-manifest.js";
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -65,6 +66,9 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
type TimeoutResult = "completed" | "timed_out";
@@ -132,6 +136,11 @@ export interface SubscribeOptions {
replayState?: boolean;
}
interface HydrateTimelineOptions {
force?: boolean;
broadcast?: boolean;
}
export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & {
/**
* When set, only providers in this set are scanned, in addition to the
@@ -220,7 +229,6 @@ interface StreamEventFlags {
interface HandleStreamEventOptions {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
}
interface ManagedAgentBase {
@@ -374,28 +382,6 @@ function validateAgentId(agentId: string, source: string): string {
return result.data;
}
function normalizeMessageId(messageId: string | undefined): string | undefined {
if (typeof messageId !== "string") {
return undefined;
}
const trimmed = messageId.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function isDuplicateLegacyUserMessage(
item: AgentTimelineItem,
canonicalUserMessagesById: Map<string, string>,
): boolean {
if (item.type !== "user_message") {
return false;
}
const eventMessageId = normalizeMessageId(item.messageId);
if (!eventMessageId) {
return false;
}
return canonicalUserMessagesById.get(eventMessageId) === item.text;
}
function buildExplicitTimelineSeedForRegister(
now: Date,
options:
@@ -1459,39 +1445,6 @@ export class AgentManager {
return true;
}
recordUserMessage(
agentId: string,
text: string,
options?: { messageId?: string; emitState?: boolean },
): void {
const agent = this.requireAgent(agentId);
const normalizedMessageId = normalizeMessageId(options?.messageId);
const item: AgentTimelineItem = {
type: "user_message",
text,
messageId: normalizedMessageId,
};
const updatedAt = this.touchUpdatedAt(agent);
agent.lastUserMessageAt = updatedAt;
const row = this.recordTimeline(agentId, item);
this.dispatchStream(
agentId,
{
type: "timeline",
item,
provider: agent.provider,
},
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agentId),
timestamp: row.timestamp,
},
);
if (options?.emitState !== false) {
this.emitState(agent);
}
}
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
const agent = this.requireAgent(agentId);
this.touchUpdatedAt(agent);
@@ -1984,9 +1937,47 @@ export class AgentManager {
* timeline is empty (e.g., imported agents that have provider history
* on disk but no persisted timeline rows). No-ops if already hydrated.
*/
async hydrateTimelineFromProvider(agentId: string): Promise<void> {
async hydrateTimelineFromProvider(
agentId: string,
options?: HydrateTimelineOptions,
): Promise<void> {
const agent = this.requireSessionAgent(agentId);
await this.hydrateTimelineFromLegacyProviderHistory(agent);
await this.hydrateTimelineFromLegacyProviderHistory(agent, options);
}
async rewind(agentId: string, messageId: string, mode: RewindMode): Promise<void> {
const agent = this.requireSessionAgent(agentId);
const hadActiveRun =
Boolean(agent.activeForegroundTurnId) || this.foregroundRuns.hasPendingRun(agentId);
if (hadActiveRun) {
await this.cancelAgentRun(agentId);
}
const lock = this.foregroundRuns.createPendingRun(agentId);
try {
this.logger.info(
{ agentId, provider: agent.provider, messageId, mode },
"agent.rewind.start",
);
await invokeRewindCapability(agent.session, { messageId, mode });
if (mode !== "files") {
await this.hydrateTimelineFromProvider(agentId, { force: true, broadcast: true });
}
await this.refreshRuntimeInfo(agent);
await this.persistSnapshot(agent);
this.logger.info(
{ agentId, provider: agent.provider, messageId, mode },
"agent.rewind.complete",
);
} catch (error) {
this.logger.warn(
{ err: error, agentId, provider: agent.provider, messageId, mode },
"agent.rewind.failed",
);
throw error;
} finally {
this.foregroundRuns.settlePendingRun(agentId, lock.token);
}
}
async deleteCommittedTimeline(agentId: string): Promise<void> {
@@ -2074,19 +2065,6 @@ export class AgentManager {
return await this.durableTimelineStore.getLastItem(agentId);
}
private async hasCommittedUserMessageFromStores(
agentId: string,
options: { messageId: string; text: string },
): Promise<boolean> {
if (this.timelineStore.hasCommittedUserMessage(agentId, options)) {
return true;
}
if (!this.durableTimelineStore) {
return false;
}
return await this.durableTimelineStore.hasCommittedUserMessage(agentId, options);
}
async waitForAgentEvent(
agentId: string,
options?: WaitForAgentOptions,
@@ -2627,20 +2605,53 @@ export class AgentManager {
}
}
private async hydrateTimelineFromLegacyProviderHistory(agent: ActiveManagedAgent): Promise<void> {
if (agent.historyPrimed) {
private async hydrateTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
options?: HydrateTimelineOptions,
): Promise<void> {
if (agent.historyPrimed && !options?.force) {
return;
}
if (options?.force) {
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
for await (const event of agent.session.streamHistory()) {
if (event.type === "timeline") {
historyEvents.push(event);
}
}
this.agentStreamCoalescer.flushAndDiscard(agent.id);
await this.deleteCommittedTimeline(agent.id);
this.timelineStore.delete(agent.id);
this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() });
agent.historyPrimed = true;
for (const event of historyEvents) {
const row = this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (options?.broadcast) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return;
}
agent.historyPrimed = true;
const canonicalUserMessagesById = this.timelineStore.getCanonicalUserMessagesById(agent.id);
try {
for await (const event of agent.session.streamHistory()) {
if (event.type !== "timeline") {
continue;
}
if (isDuplicateLegacyUserMessage(event.item, canonicalUserMessagesById)) {
continue;
}
this.recordTimeline(
agent.id,
event.item,
@@ -2882,42 +2893,11 @@ export class AgentManager {
private async onStreamTimelineEvent(params: {
agent: ActiveManagedAgent;
event: Extract<AgentStreamEvent, { type: "timeline" }>;
options:
| {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
}
| undefined;
options: { fromHistory?: boolean } | undefined;
isForegroundEvent: boolean;
flags: StreamEventFlags;
}): Promise<void> {
const { agent, event, options, isForegroundEvent, flags } = params;
// Skip provider-replayed user_message items during history hydration.
if (options?.fromHistory && event.item.type === "user_message") {
const eventMessageId = normalizeMessageId(event.item.messageId);
if (eventMessageId) {
const canonicalText = options?.canonicalUserMessagesById?.get(eventMessageId);
if (canonicalText === event.item.text) {
flags.shouldDispatchEvent = false;
flags.shouldNotifyWaiters = false;
return;
}
}
}
// Suppress user_message echoes for the active foreground turn.
if (!options?.fromHistory && event.item.type === "user_message" && isForegroundEvent) {
const eventMessageId = normalizeMessageId(event.item.messageId);
if (
eventMessageId &&
(await this.hasCommittedUserMessageFromStores(agent.id, {
messageId: eventMessageId,
text: event.item.text,
}))
) {
return;
}
}
const { agent, event, options, flags } = params;
if (options?.fromHistory) {
this.recordTimeline(
@@ -2971,12 +2951,7 @@ export class AgentManager {
event: Extract<AgentStreamEvent, { type: "turn_failed" }>;
eventTurnId: string | undefined;
isForegroundEvent: boolean;
options:
| {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
}
| undefined;
options: { fromHistory?: boolean } | undefined;
}): Promise<void> {
const { agent, event, eventTurnId, isForegroundEvent, options } = params;
this.logger.warn(
@@ -3139,10 +3114,7 @@ export class AgentManager {
agent: ActiveManagedAgent,
provider: AgentProvider,
message: string,
options?: {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
},
options?: { fromHistory?: boolean },
): Promise<void> {
if (options?.fromHistory) {
return;
@@ -196,6 +196,9 @@ export function buildStoredAgentPayload(
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
} as const;
const createdAt = new Date(record.createdAt);
@@ -118,7 +118,7 @@ export interface SendPromptToAgentParams {
agentId: string;
/** Prompt to dispatch to the provider (may include image blocks or wrapped text). */
prompt: AgentPromptInput;
/** Raw user text to record in the timeline. Required when recordUserMessage is true. */
/** Raw user text. Required when userMessageVisible is true. */
userMessageText?: string;
messageId?: string;
runOptions?: AgentRunOptions;
@@ -131,17 +131,17 @@ export interface SendPromptToAgentParams {
*/
unarchive?: boolean;
/**
* Default true. When false, the prompt is dispatched to the provider but
* no user-message turn is added to the timeline — the app shows nothing.
* Default true. When false, the prompt is dispatched to the provider as an
* invisible/system prompt — the app shows no user-message turn.
* Use false for system-injected prompts.
*/
recordUserMessage?: boolean;
userMessageVisible?: boolean;
logger: Logger;
}
/**
* Full send-prompt orchestration: (optional unarchive) → load → (optional
* mode change) → (optional record user message) → start run.
* mode change) → start run.
*
* Every surface that sends a prompt to an agent (Session/WS, MCP, CLI-through-MCP,
* chat mentions, notify-on-finish) MUST go through this so behavior can never
@@ -154,10 +154,10 @@ export async function sendPromptToAgent(
params: SendPromptToAgentParams,
): Promise<{ outOfBand: boolean }> {
const unarchive = params.unarchive ?? true;
const recordUserMessage = params.recordUserMessage ?? true;
const userMessageVisible = params.userMessageVisible ?? true;
if (recordUserMessage && params.userMessageText === undefined) {
throw new Error("userMessageText is required when recordUserMessage is true");
if (userMessageVisible && params.userMessageText === undefined) {
throw new Error("userMessageText is required when userMessageVisible is true");
}
const record = await params.agentStorage.get(params.agentId);
@@ -178,17 +178,6 @@ export async function sendPromptToAgent(
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
}
if (recordUserMessage && params.userMessageText !== undefined) {
try {
params.agentManager.recordUserMessage(params.agentId, params.userMessageText, {
messageId: params.messageId,
emitState: false,
});
} catch (error) {
params.logger.error({ err: error, agentId: params.agentId }, "Failed to record user message");
}
}
return startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
replaceRunning: true,
runOptions: params.runOptions,
@@ -225,7 +214,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
agentId: callerAgentId,
prompt: formatSystemNotificationPrompt(body),
unarchive: false,
recordUserMessage: false,
userMessageVisible: false,
logger,
});
}
@@ -123,6 +123,9 @@ export interface AgentCapabilityFlags {
supportsMcpServers: boolean;
supportsReasoningStream: boolean;
supportsToolInvocations: boolean;
supportsRewindConversation?: boolean;
supportsRewindFiles?: boolean;
supportsRewindBoth?: boolean;
}
export interface AgentPersistenceHandle {
@@ -144,6 +147,7 @@ export interface AgentRunOptions {
outputSchema?: unknown;
resumeFrom?: AgentPersistenceHandle;
maxThinkingTokens?: number;
messageId?: string;
}
export interface AgentUsage {
@@ -545,6 +549,9 @@ export interface AgentSession {
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
setFeature?(featureId: string, value: unknown): Promise<void>;
revertConversation?(input: { messageId: string }): Promise<void>;
revertFiles?(input: { messageId: string }): Promise<void>;
revertBoth?(input: { messageId: string }): Promise<void>;
/**
* Out-of-band prompt handler. When non-null, the manager runs the returned
* handler instead of allocating a turn. The handler emits stream events
@@ -56,10 +56,6 @@ export interface AgentTimelineStore {
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
getLastAssistantMessage(agentId: string): Promise<string | null>;
hasCommittedUserMessage(
agentId: string,
options: { messageId: string; text: string },
): Promise<boolean>;
deleteAgent(agentId: string): Promise<void>;
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
}
@@ -26,14 +26,6 @@ function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
return { ...row };
}
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
if (typeof messageId !== "string") {
return undefined;
}
const normalized = messageId.trim();
return normalized.length > 0 ? normalized : undefined;
}
interface FetchContext {
state: AgentTimelineState;
direction: NonNullable<AgentTimelineFetchOptions["direction"]>;
@@ -282,35 +274,6 @@ export class InMemoryAgentTimelineStore {
return chunks.toReversed().join("");
}
getCanonicalUserMessagesById(agentId: string): Map<string, string> {
const entries = this.requireState(agentId).rows.flatMap<[string, string]>((row) => {
if (row.item.type !== "user_message") {
return [];
}
const messageId = normalizeTimelineMessageId(row.item.messageId);
if (!messageId) {
return [];
}
return [[messageId, row.item.text]];
});
return new Map(entries);
}
hasCommittedUserMessage(agentId: string, options: { messageId: string; text: string }): boolean {
const messageId = normalizeTimelineMessageId(options.messageId);
if (!messageId) {
return false;
}
return this.requireState(agentId).rows.some((row) => {
if (row.item.type !== "user_message") {
return false;
}
const rowMessageId = normalizeTimelineMessageId(row.item.messageId);
return rowMessageId === messageId && row.item.text === options.text;
});
}
private requireState(agentId: string): AgentTimelineState {
const state = this.states.get(agentId);
if (!state) {
@@ -116,7 +116,6 @@ function buildAgentManagerSpies() {
return {
createAgent: vi.fn(),
waitForAgentEvent: vi.fn(),
recordUserMessage: vi.fn(),
setAgentMode: vi.fn().mockResolvedValue(undefined),
setAgentModel: vi.fn().mockResolvedValue(undefined),
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
@@ -1089,14 +1089,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
logger: childLogger,
});
try {
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
emitState: false,
});
} catch (error) {
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to record initial prompt");
}
try {
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
if (notifyOnFinish && callerAgentId) {
@@ -85,7 +85,7 @@ describe("createProviderEnv", () => {
expect(env.PATH).toBe("/custom/path");
});
test("strips parent Claude Code session env vars", () => {
test("strips parent Claude Code session env vars without removing SDK child flags", () => {
const base = {
PATH: "/usr/bin",
CLAUDECODE: "1",
@@ -102,7 +102,7 @@ describe("createProviderEnv", () => {
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
expect(env.CLAUDE_CODE_SSE_PORT).toBeUndefined();
expect(env.CLAUDE_AGENT_SDK_VERSION).toBeUndefined();
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined();
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBe("true");
});
});
@@ -174,7 +174,6 @@ const PARENT_SESSION_ENV_VARS = [
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SSE_PORT",
"CLAUDE_AGENT_SDK_VERSION",
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
];
export interface ProviderEnvOptions {
@@ -0,0 +1,197 @@
import { describe, expect, test } from "vitest";
import type {
AgentCapabilityFlags,
AgentPromptInput,
AgentSession,
AgentStreamEvent,
AgentRuntimeInfo,
} from "./agent-sdk-types.js";
import { wrapSessionProvider } from "./provider-registry.js";
type OptionalAgentSessionMethodName = {
[K in keyof AgentSession]-?: undefined extends AgentSession[K]
? NonNullable<AgentSession[K]> extends (...args: never[]) => unknown
? K
: never
: never;
}[keyof AgentSession];
const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
"listCommands",
"setModel",
"setThinkingOption",
"setFeature",
"revertConversation",
"revertFiles",
"revertBoth",
"tryHandleOutOfBand",
] as const satisfies readonly OptionalAgentSessionMethodName[];
type MissingOptionalAgentSessionMethod = Exclude<
OptionalAgentSessionMethodName,
(typeof OPTIONAL_AGENT_SESSION_METHOD_NAMES)[number]
>;
const _allOptionalAgentSessionMethodsAreCovered: MissingOptionalAgentSessionMethod extends never
? true
: never = true;
const CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsRewindBoth: true,
};
const RUNTIME_INFO: AgentRuntimeInfo = {
provider: "claude",
sessionId: "session-1",
};
class FakeSession implements AgentSession {
readonly provider = "claude";
readonly id = "session-1";
readonly capabilities = CAPABILITIES;
readonly features = [];
readonly recordedCalls: string[] = [];
async run() {
this.recordedCalls.push("run");
return { timeline: [] };
}
async startTurn() {
this.recordedCalls.push("startTurn");
return { turnId: "turn-1" };
}
subscribe(_callback: (event: AgentStreamEvent) => void) {
this.recordedCalls.push("subscribe");
return () => {};
}
async *streamHistory() {
this.recordedCalls.push("streamHistory");
yield* emptyHistory();
}
async getRuntimeInfo() {
this.recordedCalls.push("getRuntimeInfo");
return RUNTIME_INFO;
}
async getAvailableModes() {
this.recordedCalls.push("getAvailableModes");
return [];
}
async getCurrentMode() {
this.recordedCalls.push("getCurrentMode");
return null;
}
async setMode(_modeId: string) {
this.recordedCalls.push("setMode");
}
getPendingPermissions() {
this.recordedCalls.push("getPendingPermissions");
return [];
}
async respondToPermission() {
this.recordedCalls.push("respondToPermission");
}
describePersistence() {
this.recordedCalls.push("describePersistence");
return null;
}
async interrupt() {
this.recordedCalls.push("interrupt");
}
async close() {
this.recordedCalls.push("close");
}
async listCommands() {
this.recordedCalls.push("listCommands");
return [];
}
async setModel() {
this.recordedCalls.push("setModel");
}
async setThinkingOption() {
this.recordedCalls.push("setThinkingOption");
}
async setFeature() {
this.recordedCalls.push("setFeature");
}
async revertConversation() {
this.recordedCalls.push("revertConversation");
}
async revertFiles() {
this.recordedCalls.push("revertFiles");
}
async revertBoth() {
this.recordedCalls.push("revertBoth");
}
tryHandleOutOfBand(_prompt: AgentPromptInput) {
this.recordedCalls.push("tryHandleOutOfBand");
return {
run: async () => {
this.recordedCalls.push("tryHandleOutOfBand.run");
},
};
}
}
async function* emptyHistory(): AsyncGenerator<AgentStreamEvent> {
for (const event of [] as AgentStreamEvent[]) {
yield event;
}
}
describe("wrapSessionProvider", () => {
test("forwards every optional AgentSession method", async () => {
const session = new FakeSession();
const wrapped = wrapSessionProvider("custom-claude", session);
await wrapped.listCommands?.();
await wrapped.setModel?.("sonnet");
await wrapped.setThinkingOption?.("high");
await wrapped.setFeature?.("feature-1", true);
await wrapped.revertConversation?.({ messageId: "message-1" });
await wrapped.revertFiles?.({ messageId: "message-1" });
await wrapped.revertBoth?.({ messageId: "message-1" });
const handler = wrapped.tryHandleOutOfBand?.("/compact");
await handler?.run({ emit: () => {} });
expect(session.recordedCalls).toEqual([
"listCommands",
"setModel",
"setThinkingOption",
"setFeature",
"revertConversation",
"revertFiles",
"revertBoth",
"tryHandleOutOfBand",
"tryHandleOutOfBand.run",
]);
});
});
@@ -331,7 +331,7 @@ function mergeModelAdditions(
);
}
function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession {
export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession {
return {
provider,
id: inner.id,
@@ -360,6 +360,10 @@ function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): Agen
setModel: inner.setModel?.bind(inner),
setThinkingOption: inner.setThinkingOption?.bind(inner),
setFeature: inner.setFeature?.bind(inner),
revertConversation: inner.revertConversation?.bind(inner),
revertFiles: inner.revertFiles?.bind(inner),
revertBoth: inner.revertBoth?.bind(inner),
tryHandleOutOfBand: inner.tryHandleOutOfBand?.bind(inner),
};
}
@@ -158,6 +158,9 @@ const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
@@ -1519,6 +1519,12 @@ describe("ClaudeAgentSession context window usage", () => {
const result = await session.run("turn");
await session.close();
expect(result.timeline).toEqual([{ type: "assistant_message", text: "Here is the answer." }]);
expect(result.timeline).toEqual([
{
type: "assistant_message",
text: "Here is the answer.",
messageId: "assistant-third-party-1",
},
]);
});
});
@@ -41,6 +41,7 @@ import {
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
import {
@@ -191,6 +192,15 @@ interface PersistedTimelineEntry {
timestamp?: string;
}
interface ClaudeRewindTurnAnchor {
userMessageId: string;
assistantMessageId: string | null;
}
type ClaudeConversationRewindTarget =
| { kind: "fresh-session" }
| { kind: "fork"; messageId: string };
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -198,6 +208,9 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsRewindBoth: true,
};
const DEFAULT_MODES: AgentMode[] = [
@@ -1046,7 +1059,7 @@ class TimelineAssembler {
!isClaudeTranscriptNoiseText(nextAssistantText)
) {
state.emittedAssistantLength = state.assistantText.length;
items.push({ type: "assistant_message", text: nextAssistantText });
items.push({ type: "assistant_message", text: nextAssistantText, messageId: state.id });
}
const nextReasoningText = state.reasoningText.slice(state.emittedReasoningLength);
@@ -1157,7 +1170,25 @@ function isSyntheticUserEntry(entry: unknown): boolean {
if (!candidate) {
return false;
}
return candidate.isSynthetic === true || candidate.isMeta === true;
return (
candidate.isSynthetic === true || candidate.isMeta === true || Boolean(candidate.toolUseResult)
);
}
function isToolResultUserEntry(entry: unknown): boolean {
const candidate = toObjectRecord(entry);
if (!candidate) {
return false;
}
const message = toObjectRecord(candidate.message);
const content = message?.content;
return (
Array.isArray(content) && content.some((block) => toObjectRecord(block)?.type === "tool_result")
);
}
function isSyntheticHistoryUserEntry(entry: Record<string, unknown>): boolean {
return isSyntheticUserEntry(entry);
}
function firstTrimmedString(sources: readonly unknown[]): string | null {
@@ -1170,6 +1201,15 @@ function firstTrimmedString(sources: readonly unknown[]): string | null {
return null;
}
function readTranscriptUuid(message: SDKMessage): string | null {
const root = toObjectRecord(message) ?? {};
const messageType = readTrimmedString(root.type);
if (messageType !== "user" && messageType !== "assistant") {
return null;
}
return firstTrimmedString([root.uuid]);
}
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
const root = toObjectRecord(message) ?? {};
const messageType = readTrimmedString(root.type);
@@ -1177,7 +1217,10 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
const streamEventMessage = toObjectRecord(streamEvent?.message);
const messageContainer = toObjectRecord(root.message);
const messageIdFromUuid = messageType === "user" ? root.uuid : undefined;
const messageIdFromUuid =
messageType === "user" || messageType === "assistant" || messageType === "system"
? root.uuid
: undefined;
return {
taskId: firstTrimmedString([
@@ -1566,7 +1609,6 @@ class ClaudeAgentSession implements AgentSession {
private queryPumpPromise: Promise<void> | null = null;
private queryRestartNeeded = false;
private pendingInterruptAbort = false;
private lastForegroundPromptText: string | null = null;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
private lastContextWindowUsedTokens: number | undefined;
@@ -1574,6 +1616,9 @@ class ClaudeAgentSession implements AgentSession {
private lastStreamRequestInputTokens: number | undefined;
private lastStreamRequestOutputTokens: number | undefined;
private userMessageIds: string[] = [];
private readonly emittedUserMessageIds = new Set<string>();
private readonly rewindTurnAnchors: ClaudeRewindTurnAnchor[] = [];
private pendingFreshSessionId: string | null = null;
private recentStderr = "";
private closed = false;
@@ -1689,7 +1734,9 @@ class ClaudeAgentSession implements AgentSession {
}
const sdkMessage = this.toSdkUserMessage(prompt);
this.lastForegroundPromptText = this.extractPromptText(prompt);
const sdkUserMessageId =
typeof sdkMessage.uuid === "string" && sdkMessage.uuid.length > 0 ? sdkMessage.uuid : null;
this.rememberRewindUserAnchor(sdkUserMessageId);
const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
@@ -1727,6 +1774,11 @@ class ClaudeAgentSession implements AgentSession {
}
this.startQueryPump();
this.input.push(sdkMessage);
setTimeout(() => {
if (this.activeForegroundTurnId === turnId) {
this.emitSubmittedUserMessage(sdkMessage, turnId);
}
}, 0);
} catch (error) {
this.finishForegroundTurn(
this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"),
@@ -2004,6 +2056,36 @@ class ClaudeAgentSession implements AgentSession {
return Array.from(commandMap.values()).sort((a, b) => a.name.localeCompare(b.name));
}
async revertConversation(input: { messageId: string }): Promise<void> {
const target = this.resolveConversationRewindTarget(input.messageId);
if (target.kind === "fresh-session") {
this.startFreshConversationSession();
return;
}
await revertClaudeConversation({
sdk: realClaudeRewindSdk,
sessionId: this.claudeSessionId,
messageId: target.messageId,
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
setSessionId: (sessionId) => {
this.rebindConversationSession(sessionId);
},
});
}
async revertFiles(input: { messageId: string }): Promise<void> {
const messageId = await this.resolveClaudeMessageId(input.messageId);
await revertClaudeFiles({
query: await this.ensureQuery(),
messageId,
});
}
async revertBoth(input: { messageId: string }): Promise<void> {
await this.revertFiles(input);
await this.revertConversation(input);
}
private resolveSlashCommandInvocation(prompt: AgentPromptInput): SlashCommandInvocation | null {
if (typeof prompt !== "string") {
return null;
@@ -2150,10 +2232,6 @@ class ClaudeAgentSession implements AgentSession {
}
};
const historyIds = this.readUserMessageIdsFromHistoryFile();
for (let idx = historyIds.length - 1; idx >= 0; idx -= 1) {
pushUnique(historyIds[idx]);
}
for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) {
const entry = this.persistedHistory[idx];
if (entry?.item.type === "user_message") {
@@ -2167,35 +2245,49 @@ class ClaudeAgentSession implements AgentSession {
return candidates;
}
private readUserMessageIdsFromHistoryFile(): string[] {
if (!this.claudeSessionId) {
return [];
}
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
if (!historyPath || !fs.existsSync(historyPath)) {
return [];
}
try {
const ids: string[] = [];
const content = fs.readFileSync(historyPath, "utf8");
for (const line of content.split(/\n+/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed);
if (entry?.type === "user" && typeof entry.uuid === "string") {
ids.push(entry.uuid);
}
} catch {
// ignore malformed lines
}
}
return ids;
} catch {
return [];
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.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
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 {
const sessionId = randomUUID();
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = sessionId;
this.persistence = null;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
}
private rememberUserMessageId(messageId: string | null | undefined): void {
if (typeof messageId !== "string" || messageId.length === 0) {
return;
@@ -2207,6 +2299,92 @@ class ClaudeAgentSession implements AgentSession {
this.userMessageIds.push(messageId);
}
private rememberEmittedUserMessageId(messageId: string | null | undefined): void {
if (typeof messageId !== "string" || messageId.length === 0) {
return;
}
this.emittedUserMessageIds.add(messageId);
}
private rememberRewindUserAnchor(userMessageId: string | null | undefined): void {
if (typeof userMessageId !== "string" || userMessageId.length === 0) {
return;
}
if (this.rewindTurnAnchors.some((anchor) => anchor.userMessageId === userMessageId)) {
return;
}
this.rewindTurnAnchors.push({
userMessageId,
assistantMessageId: null,
});
}
private rememberRewindAssistantAnchor(assistantMessageId: string | null | undefined): void {
if (typeof assistantMessageId !== "string" || assistantMessageId.length === 0) {
return;
}
for (let index = this.rewindTurnAnchors.length - 1; index >= 0; index -= 1) {
const anchor = this.rewindTurnAnchors[index];
if (!anchor) {
continue;
}
anchor.assistantMessageId = assistantMessageId;
return;
}
}
private rememberTranscriptProgress(message: SDKMessage, messageId: string | null): void {
if (!messageId) {
return;
}
if (
message.type === "user" &&
!isSyntheticUserEntry(message) &&
!isToolResultUserEntry(message)
) {
this.rememberRewindUserAnchor(messageId);
return;
}
if (message.type === "assistant") {
this.rememberRewindAssistantAnchor(messageId);
return;
}
if (message.type === "stream_event") {
const event = toObjectRecord(message.event) ?? {};
const eventType = readTrimmedString(event.type);
if (eventType === "message_start") {
this.rememberRewindAssistantAnchor(messageId);
}
return;
}
}
private resolveClaudeMessageId(messageId: string): string {
return messageId;
}
private resolveConversationRewindTarget(messageId: string): ClaudeConversationRewindTarget {
const targetUserMessageId = this.resolveClaudeMessageId(messageId);
const index = this.rewindTurnAnchors.findIndex(
(anchor) => anchor.userMessageId === targetUserMessageId,
);
if (index < 0) {
throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`);
}
if (index === 0) {
return { kind: "fresh-session" };
}
const previousTurn = this.rewindTurnAnchors[index - 1];
if (!previousTurn?.assistantMessageId) {
throw new Error(
`Claude rewind cannot preserve turn ${index} because its assistant response id was not observed`,
);
}
return { kind: "fork", messageId: previousTurn.assistantMessageId };
}
private async ensureQuery(): Promise<Query> {
if (this.query && !this.queryRestartNeeded) {
return this.query;
@@ -2353,6 +2531,13 @@ class ClaudeAgentSession implements AgentSession {
},
"Resolved Claude executable",
);
const sessionBinding: Pick<ClaudeOptions, "resume" | "sessionId"> = {};
if (this.pendingFreshSessionId) {
sessionBinding.sessionId = this.pendingFreshSessionId;
} else if (this.claudeSessionId) {
sessionBinding.resume = this.claudeSessionId;
}
const base: ClaudeOptions = {
cwd: this.config.cwd,
includePartialMessages: true,
@@ -2380,7 +2565,7 @@ class ClaudeAgentSession implements AgentSession {
enableFileCheckpointing: true,
// If we have a session ID from a previous query (e.g., after interrupt),
// resume that session to continue the conversation history.
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
...sessionBinding,
...(thinking ? { thinking } : {}),
...(effort ? { effort } : {}),
...extraClaudeOptions,
@@ -2396,7 +2581,7 @@ class ClaudeAgentSession implements AgentSession {
base.model = this.config.model;
}
this.lastOptionsModel = base.model ?? null;
if (this.claudeSessionId) {
if (this.claudeSessionId && !this.pendingFreshSessionId) {
base.resume = this.claudeSessionId;
}
if (this.runtimeSettings?.disallowedTools?.length) {
@@ -2559,16 +2744,6 @@ class ClaudeAgentSession implements AgentSession {
);
}
private extractPromptText(prompt: AgentPromptInput): string | null {
if (typeof prompt === "string") {
return prompt;
}
const textParts = prompt
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text);
return textParts.length > 0 ? textParts.join("\n") : null;
}
private async executeRewindTurn(
_turnId: string,
invocation: SlashCommandInvocation,
@@ -2630,7 +2805,6 @@ class ClaudeAgentSession implements AgentSession {
}
this.notifySubscribers(event);
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("foreground turn terminal");
@@ -2646,7 +2820,6 @@ class ClaudeAgentSession implements AgentSession {
if (terminalSeen) {
if (this.activeForegroundTurnId) {
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("foreground turn terminal");
@@ -2808,18 +2981,6 @@ class ClaudeAgentSession implements AgentSession {
}
}
private isEchoedForegroundUserMessage(event: AgentStreamEvent): boolean {
if (
event.type !== "timeline" ||
event.item.type !== "user_message" ||
!this.activeForegroundTurnId ||
!this.lastForegroundPromptText
) {
return false;
}
return event.item.text.trim() === this.lastForegroundPromptText.trim();
}
private shouldSuppressStaleResult(message: SDKMessage): boolean {
// Suppress stale results from interrupted requests. The cancel path already
// emitted the terminal event; this result is leftover from the killed API
@@ -2862,6 +3023,7 @@ class ClaudeAgentSession implements AgentSession {
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
const identifiers = readEventIdentifiers(message);
this.rememberTranscriptProgress(message, readTranscriptUuid(message));
this.logger.trace(
{
@@ -2895,12 +3057,7 @@ class ClaudeAgentSession implements AgentSession {
}) satisfies AgentStreamEvent,
);
// User message dedup: suppress echoed user messages that match the foreground prompt
const filteredMessageEvents = messageEvents.filter(
(event) => !this.isEchoedForegroundUserMessage(event),
);
const events = [...filteredMessageEvents, ...assistantTimelineEvents];
const events = [...messageEvents, ...assistantTimelineEvents];
if (events.length === 0) {
return;
@@ -3013,20 +3170,22 @@ class ClaudeAgentSession implements AgentSession {
}
const events: AgentStreamEvent[] = [];
const sessionCapture = this.captureSessionIdFromMessage(message);
if (sessionCapture.notice) {
events.push({
type: "timeline",
provider: "claude",
item: sessionCapture.notice,
});
}
if (sessionCapture.threadStartedSessionId) {
events.push({
type: "thread_started",
provider: "claude",
sessionId: sessionCapture.threadStartedSessionId,
});
if (message.type !== "system") {
const sessionCapture = this.captureSessionIdFromMessage(message);
if (sessionCapture.notice) {
events.push({
type: "timeline",
provider: "claude",
item: sessionCapture.notice,
});
}
if (sessionCapture.threadStartedSessionId) {
events.push({
type: "thread_started",
provider: "claude",
sessionId: sessionCapture.threadStartedSessionId,
});
}
}
switch (message.type) {
@@ -3059,6 +3218,25 @@ class ClaudeAgentSession implements AgentSession {
return events;
}
private emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>,
turnId: string,
): void {
const events: AgentStreamEvent[] = [];
this.appendUserMessageEvents(message, events);
if (events.length === 0) {
return;
}
this.foregroundHasVisibleActivity = true;
for (const event of events) {
if (event.type === "timeline") {
this.notifySubscribers({ ...event, turnId });
} else {
this.notifySubscribers(event);
}
}
}
private appendSystemMessageEvents(
message: Extract<SDKMessage, { type: "system" }>,
events: AgentStreamEvent[],
@@ -3162,7 +3340,11 @@ class ClaudeAgentSession implements AgentSession {
}
const messageId =
typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
if (messageId && this.emittedUserMessageIds.has(messageId)) {
return;
}
this.rememberUserMessageId(messageId);
this.rememberEmittedUserMessageId(messageId);
const content = message.message?.content;
const taskNotificationItem = mapTaskNotificationUserContentToToolCall({
content,
@@ -3293,10 +3475,12 @@ class ClaudeAgentSession implements AgentSession {
}
if (this.claudeSessionId === null) {
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
return { threadStartedSessionId: sessionId, notice: null };
}
if (this.claudeSessionId === sessionId) {
this.pendingFreshSessionId = null;
return { threadStartedSessionId: null, notice: null };
}
const oldSessionId = this.claudeSessionId;
@@ -3308,6 +3492,7 @@ class ClaudeAgentSession implements AgentSession {
"Claude session ID changed in message; accepting new session",
);
this.claudeSessionId = sessionId;
this.pendingFreshSessionId = null;
this.persistence = null;
return {
threadStartedSessionId: sessionId,
@@ -3338,9 +3523,11 @@ class ClaudeAgentSession implements AgentSession {
if (existingSessionId === null) {
this.claudeSessionId = newSessionId;
this.pendingFreshSessionId = null;
threadStartedSessionId = newSessionId;
this.logger.debug({ sessionId: newSessionId }, "Claude session ID set for the first time");
} else if (existingSessionId === newSessionId) {
this.pendingFreshSessionId = null;
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
} else {
// Session ID changed in an init message (e.g. a hook restarted Claude
@@ -3350,6 +3537,7 @@ class ClaudeAgentSession implements AgentSession {
"Claude session ID changed in init message; accepting new session",
);
this.claudeSessionId = newSessionId;
this.pendingFreshSessionId = null;
threadStartedSessionId = newSessionId;
notice = this.createClaudeSessionChangedNotice(existingSessionId, newSessionId);
}
@@ -3703,12 +3891,22 @@ class ClaudeAgentSession implements AgentSession {
if (entry.isSidechain) {
return;
}
if (entry.type === "user" && typeof entry.uuid === "string") {
this.rememberUserMessageId(entry.uuid);
}
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = this.convertHistoryEntry(entry);
const isVisibleUserEntry =
entry.type === "user" &&
typeof entry.uuid === "string" &&
!isSyntheticHistoryUserEntry(entry) &&
!isToolResultUserEntry(entry);
if (isVisibleUserEntry && typeof entry.uuid === "string") {
this.rememberUserMessageId(entry.uuid);
this.rememberRewindUserAnchor(entry.uuid);
}
if (entry.type === "assistant" && typeof entry.uuid === "string") {
this.rememberRewindAssistantAnchor(entry.uuid);
}
if (items.length > 0) {
timeline.push(
...items.map((item) => ({
@@ -3722,10 +3920,25 @@ class ClaudeAgentSession implements AgentSession {
private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd;
if (!cwd) return null;
const sanitized = sanitizeClaudeProjectPath(cwd);
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const dir = path.join(configDir, "projects", sanitized);
return path.join(dir, `${sessionId}.jsonl`);
const candidates = [cwd];
try {
const realCwd = fs.realpathSync(cwd);
if (realCwd !== cwd) {
candidates.push(realCwd);
}
} catch {
// Fall back to the configured cwd when the path has already disappeared.
}
for (const candidate of candidates) {
const sanitized = sanitizeClaudeProjectPath(candidate);
const historyPath = path.join(configDir, "projects", sanitized, `${sessionId}.jsonl`);
if (fs.existsSync(historyPath)) {
return historyPath;
}
}
const sanitized = sanitizeClaudeProjectPath(cwd);
return path.join(configDir, "projects", sanitized, `${sessionId}.jsonl`);
}
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
@@ -4409,6 +4622,25 @@ interface ClaudeHistoryEntry {
[key: string]: unknown;
}
function mapAssistantHistoryBlocksWithMessageId(
entry: ClaudeHistoryEntry,
content: string | ClaudeContentChunk[],
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) {
return items;
}
for (const item of items) {
if (item.type === "assistant_message" && !item.messageId) {
item.messageId = assistantMessageId;
}
}
return items;
}
function convertClaudeHistoryEntryPreamble(
entry: ClaudeHistoryEntry,
): { shortCircuit: AgentTimelineItem[] } | { proceed: { content: unknown } } {
@@ -4504,7 +4736,7 @@ export function convertClaudeHistoryEntry(
}
if (entry.type === "assistant" && contentValue) {
return mapBlocks(contentValue);
return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
}
return timeline;
@@ -0,0 +1,89 @@
import { describe, expect, test } from "vitest";
import type { Query } from "@anthropic-ai/claude-agent-sdk";
import {
revertClaudeConversation,
revertClaudeConversationAndFiles,
revertClaudeFiles,
} from "./rewind.js";
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
describe("Claude rewind", () => {
test("forks the conversation up to the user message", async () => {
const claude = new FakeClaudeSdk();
let sessionId = "original-session";
await revertClaudeConversation({
sdk: claude,
sessionId,
messageId: "user-message-1",
setSessionId: (nextSessionId) => {
sessionId = nextSessionId;
},
});
expect(claude.recordedForks).toEqual([{ upToMessageId: "user-message-1" }]);
expect(sessionId).toBe("forked-session-1");
});
test("translates Paseo timeline message ids before forking", async () => {
const claude = new FakeClaudeSdk();
let sessionId = "original-session";
await revertClaudeConversation({
sdk: claude,
sessionId,
messageId: "timeline-message-1",
resolveMessageId: () => "claude-jsonl-message-1",
setSessionId: (nextSessionId) => {
sessionId = nextSessionId;
},
});
expect(claude.recordedForks).toEqual([{ upToMessageId: "claude-jsonl-message-1" }]);
expect(sessionId).toBe("forked-session-1");
});
test("rewinds tracked files to the user message", async () => {
const claude = new FakeClaudeSdk();
await revertClaudeFiles({
query: claude.createQuery() as Query,
messageId: "user-message-1",
});
expect(claude.recordedFileRewinds).toEqual([{ userMessageId: "user-message-1" }]);
});
test("translates Paseo timeline message ids before rewinding files", async () => {
const claude = new FakeClaudeSdk();
await revertClaudeFiles({
query: claude.createQuery() as Query,
messageId: "timeline-message-1",
resolveMessageId: () => "claude-jsonl-message-1",
});
expect(claude.recordedFileRewinds).toEqual([{ userMessageId: "claude-jsonl-message-1" }]);
});
test("rebinds the Claude session before composed rewind returns for rehydrate", async () => {
const claude = new FakeClaudeSdk();
claude.setNextSessionId("forked-before-rehydrate");
let sessionId = "original-session";
await revertClaudeConversationAndFiles({
sdk: claude,
query: claude.createQuery() as Query,
sessionId,
messageId: "user-message-1",
setSessionId: (nextSessionId) => {
sessionId = nextSessionId;
},
});
expect(claude.recordedFileRewinds).toEqual([{ userMessageId: "user-message-1" }]);
expect(claude.recordedForks).toEqual([{ upToMessageId: "user-message-1" }]);
expect(sessionId).toBe("forked-before-rehydrate");
});
});
@@ -0,0 +1,57 @@
import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
export interface ClaudeRewindSdk {
forkSession(
sessionId: string,
options: { upToMessageId: string },
): Promise<{ sessionId: string }>;
}
export const realClaudeRewindSdk: ClaudeRewindSdk = {
forkSession: claudeForkSession,
};
export async function revertClaudeConversation(input: {
sdk: ClaudeRewindSdk;
sessionId: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
}): Promise<void> {
if (!input.sessionId) {
throw new Error("Claude session is not ready for rewind");
}
const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId;
const fork = await input.sdk.forkSession(input.sessionId, {
upToMessageId: messageId,
});
input.setSessionId(fork.sessionId);
}
export async function revertClaudeFiles(input: {
query: Query;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
}): Promise<void> {
const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId;
const result = await input.query.rewindFiles(messageId, { dryRun: false });
if (!result.canRewind) {
throw new Error(result.error ?? `No file checkpoint found for message ${messageId}`);
}
}
export async function revertClaudeConversationAndFiles(input: {
sdk: ClaudeRewindSdk;
query: Query;
sessionId: string | null;
messageId: string;
resolveMessageId?: (messageId: string) => string | Promise<string>;
setSessionId: (sessionId: string) => void;
}): Promise<void> {
await revertClaudeFiles({
query: input.query,
messageId: input.messageId,
resolveMessageId: input.resolveMessageId,
});
await revertClaudeConversation(input);
}
@@ -0,0 +1,36 @@
import type { ClaudeRewindSdk } from "./rewind.js";
export class FakeClaudeSdk implements ClaudeRewindSdk {
readonly recordedForks: Array<{ upToMessageId: string }> = [];
readonly recordedFileRewinds: Array<{ userMessageId: string }> = [];
private nextSessionId = "forked-session-1";
setNextSessionId(sessionId: string): void {
this.nextSessionId = sessionId;
}
async forkSession(
_sessionId: string,
options: { upToMessageId: string },
): Promise<{ sessionId: string }> {
this.recordedForks.push({ upToMessageId: options.upToMessageId });
return { sessionId: this.nextSessionId };
}
createQuery(): {
rewindFiles: (
userMessageId: string,
options?: { dryRun?: boolean },
) => Promise<{
canRewind: boolean;
}>;
} {
return {
rewindFiles: async (userMessageId: string) => {
this.recordedFileRewinds.push({ userMessageId });
return { canRewind: true };
},
};
}
}
@@ -2,7 +2,7 @@ import pino from "pino";
import { describe, expect, test } from "vitest";
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import { __codexAppServerInternals } from "./codex-app-server-agent.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
@@ -74,7 +74,7 @@ function createSessionHarness(
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
});
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
const session = new CodexAppServerAgentSession(
{ ...config, provider: CODEX_PROVIDER },
null,
options.logger ?? createTestLogger(),
@@ -15,12 +15,20 @@ import type {
AgentStreamEvent,
} from "../agent-sdk-types.js";
import {
__codexAppServerInternals,
buildCodexAppServerEnv,
CodexAppServerAgentClient,
CodexAppServerAgentSession,
codexAppServerTurnInputFromPrompt,
listCodexSkills,
mapCodexPatchNotificationToToolCall,
mapCodexPlanToToolCall,
normalizeCodexOutputSchema,
toAgentUsage,
} from "./codex-app-server-agent.js";
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
createFakeCodexAppServer,
type FakeCodexAppServer,
waitForNextPermission,
} from "./codex/test-utils/fake-app-server.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
@@ -76,7 +84,7 @@ function createSession(
configOverrides: Partial<AgentSessionConfig> = {},
options: { goalsEnabled?: boolean; autoReviewEnabled?: boolean } = {},
): CodexTestSession {
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
const session = new CodexAppServerAgentSession(
createConfig(configOverrides),
null,
createTestLogger(),
@@ -106,6 +114,25 @@ function markdownImageSource(markdown: string): string {
return match[1].replace(/\\\)/g, ")");
}
function emitCodexUserMessage(
appServer: FakeCodexAppServer,
input: { id: string; text: string; threadId?: string },
): void {
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/started",
params: {
threadId: input.threadId ?? "thread-1",
item: {
type: "userMessage",
id: input.id,
content: [{ type: "text", text: input.text }],
},
},
})}\n`,
);
}
type CapturedFakeCodexRecord = Record<string, unknown>;
async function runCustomCodexProviderTurn(
@@ -380,7 +407,7 @@ describe("Codex app-server provider", () => {
},
};
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
const session = new CodexAppServerAgentSession(
createConfig({ thinkingOptionId: "medium" }),
null,
createTestLogger(),
@@ -411,7 +438,7 @@ describe("Codex app-server provider", () => {
},
};
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
const session = new CodexAppServerAgentSession(
createConfig({ thinkingOptionId: "medium" }),
null,
createTestLogger(),
@@ -437,7 +464,7 @@ describe("Codex app-server provider", () => {
child.exitCode = null;
child.signalCode = null;
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger());
const client = new CodexAppServerClient(child, createTestLogger());
try {
const disposePromise = client.dispose();
@@ -459,7 +486,7 @@ describe("Codex app-server provider", () => {
"collaborationMode/list": () => ({ data: [] }),
"skills/list": () => ({ data: [] }),
});
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
@@ -507,6 +534,32 @@ describe("Codex app-server provider", () => {
await session.close();
});
test("rewinds the conversation to a freshly emitted Codex user message id", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
await session.startTurn("remember first");
emitCodexUserMessage(appServer, { id: "codex-first", text: "remember first" });
appServer.completeTurn();
await session.startTurn("remember second");
emitCodexUserMessage(appServer, { id: "codex-second", text: "remember second" });
appServer.completeTurn();
await session.revertConversation({ messageId: "codex-first" });
expect(appServer.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 2 }]);
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
sessionId: "forked-thread",
});
appServer.assertNoErrors();
await session.close();
});
test("configures Codex app-server to use a custom provider base URL", async () => {
const capturedRequests = await runCustomCodexProviderTurn(
"codex-iisb",
@@ -557,7 +610,7 @@ describe("Codex app-server provider", () => {
},
"thread/resume": () => {
threadRequests.push("thread/resume");
return Promise.reject(new Error("no rollout found for thread id archived-thread-id"));
return Promise.reject(new Error("no tool-call found for thread id archived-thread-id"));
},
"thread/start": () => {
threadRequests.push("thread/start");
@@ -604,7 +657,7 @@ describe("Codex app-server provider", () => {
(error) => {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"no rollout found for thread id archived-thread-id",
"no tool-call found for thread id archived-thread-id",
);
return "rejected" as const;
},
@@ -637,9 +690,7 @@ describe("Codex app-server provider", () => {
};
try {
await expect(
__codexAppServerInternals.listCodexSkills(cwd, workspaceGitService),
).resolves.toContainEqual({
await expect(listCodexSkills(cwd, workspaceGitService)).resolves.toContainEqual({
name: "shipper",
description: "Ship changes carefully.",
argumentHint: "",
@@ -654,7 +705,7 @@ describe("Codex app-server provider", () => {
test("extracts context window usage from snake_case token payloads", () => {
expect(
__codexAppServerInternals.toAgentUsage({
toAgentUsage({
model_context_window: 200000,
last: {
total_tokens: 50000,
@@ -674,7 +725,7 @@ describe("Codex app-server provider", () => {
test("extracts context window usage from camelCase token payloads", () => {
expect(
__codexAppServerInternals.toAgentUsage({
toAgentUsage({
modelContextWindow: 200000,
last: {
totalTokens: 50000,
@@ -694,7 +745,7 @@ describe("Codex app-server provider", () => {
test("keeps existing usage behavior when context window fields are missing", () => {
expect(
__codexAppServerInternals.toAgentUsage({
toAgentUsage({
last: {
inputTokens: 30000,
cachedInputTokens: 5000,
@@ -710,7 +761,7 @@ describe("Codex app-server provider", () => {
test("excludes invalid context window values", () => {
expect(
__codexAppServerInternals.toAgentUsage({
toAgentUsage({
model_context_window: Number.NaN,
modelContextWindow: "200000",
last: {
@@ -748,7 +799,7 @@ describe("Codex app-server provider", () => {
required: ["overall"],
};
const normalized = __codexAppServerInternals.normalizeCodexOutputSchema(input);
const normalized = normalizeCodexOutputSchema(input);
expect(normalized).toEqual({
type: "object",
@@ -1013,7 +1064,7 @@ describe("Codex app-server provider", () => {
});
test("maps patch notifications with array-style changes and alias diff keys", () => {
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
const item = mapCodexPatchNotificationToToolCall({
callId: "patch-array-alias",
changes: [
{
@@ -1036,7 +1087,7 @@ describe("Codex app-server provider", () => {
});
test("maps Codex plan markdown to a synthetic plan tool call", () => {
const item = __codexAppServerInternals.mapCodexPlanToToolCall({
const item = mapCodexPlanToToolCall({
callId: "plan-turn-1",
text: "### Login Screen\n- Build layout\n- Add validation",
});
@@ -1055,7 +1106,7 @@ describe("Codex app-server provider", () => {
});
test("maps patch notifications with object-style single change payloads", () => {
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
const item = mapCodexPatchNotificationToToolCall({
callId: "patch-object-single",
changes: {
path: "/tmp/repo/src/object-single.ts",
@@ -1076,7 +1127,7 @@ describe("Codex app-server provider", () => {
});
test("maps patch notifications with file_path aliases in array-style changes", () => {
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
const item = mapCodexPatchNotificationToToolCall({
callId: "patch-array-file-path",
changes: [
{
@@ -1105,7 +1156,7 @@ describe("Codex app-server provider", () => {
PASEO_TEST_FLAG: "codex-launch-value",
},
};
const env = __codexAppServerInternals.buildCodexAppServerEnv(
const env = buildCodexAppServerEnv(
{
env: {
PASEO_AGENT_ID: "runtime-value",
@@ -1499,6 +1550,7 @@ describe("Codex app-server provider", () => {
item: {
type: "user_message",
text: "Check OpenCode timestamps.",
messageId: "user-history",
},
},
{
@@ -1573,6 +1625,40 @@ describe("Codex app-server provider", () => {
]);
});
test("captures live Codex user message ids from item events", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const userMessageItem = {
type: "userMessage",
id: "codex-user-live-1",
content: [{ type: "text", text: "Use the native Codex id." }],
};
asInternals(session).handleNotification("item/started", {
threadId: "test-thread",
item: userMessageItem,
});
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: userMessageItem,
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "user_message",
text: "Use the native Codex id.",
messageId: "codex-user-live-1",
},
},
]);
});
test("emits Codex context compaction markers from live thread items", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
@@ -1694,7 +1780,7 @@ describe("Codex app-server provider", () => {
return { data: [] };
}
if (method === "thread/resume") {
throw new Error("no rollout found for thread id archived-thread-id");
throw new Error("no tool-call found for thread id archived-thread-id");
}
if (method === "thread/start") {
return { thread: { id: "replacement-empty-thread-id" } };
@@ -1704,7 +1790,7 @@ describe("Codex app-server provider", () => {
};
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
"no rollout found for thread id archived-thread-id",
"no tool-call found for thread id archived-thread-id",
);
expect(session.currentThreadId).toBe("archived-thread-id");
@@ -44,7 +44,7 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { composeSystemPromptParts } from "../system-prompt.js";
import { curateAgentActivity } from "../activity-curator.js";
import {
mapCodexRolloutToolCall,
mapCodexToolCallEnvelope,
mapCodexToolCallFromThreadItem,
} from "./codex/tool-call-mapper.js";
import {
@@ -60,8 +60,15 @@ import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mappe
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
import {
CodexAppServerClient,
parseCodexThreadForkResponse,
parseCodexThreadRollbackResponse,
type CodexThreadForkParams,
type CodexThreadForkResponse,
type CodexThreadRollbackParams,
type CodexThreadRollbackResponse,
type CodexAppServerTraceContext,
} from "./codex/app-server-transport.js";
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
import {
renderProviderImageOutputAsAssistantMarkdown,
type ProviderImageOutput,
@@ -158,6 +165,9 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: true,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
const CODEX_MODES: AgentMode[] = [
@@ -183,6 +193,8 @@ const DEFAULT_CODEX_MODE_ID = "auto";
interface CodexAppServerClientLike {
request(method: string, params?: unknown): Promise<unknown>;
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
rollbackThread?(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse>;
notify(method: string, params?: unknown): void;
dispose(): Promise<void>;
}
@@ -343,7 +355,7 @@ function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): un
return normalized;
}
function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
export function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
if (!isSchemaRecord(schema)) {
throw new Error("Codex structured outputs require a JSON object schema.");
}
@@ -384,7 +396,7 @@ function codexMicrosoftStorePackageRoot(): string | null {
return path.join(localAppData, "Packages");
}
async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
export async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
if (process.platform !== "win32") {
return null;
}
@@ -425,7 +437,7 @@ async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
return null;
}
async function findDefaultCodexBinary(): Promise<string | null> {
export async function findDefaultCodexBinary(): Promise<string | null> {
return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary());
}
@@ -597,7 +609,7 @@ async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
return commands.sort((a, b) => a.name.localeCompare(b.name));
}
async function listCodexSkills(
export async function listCodexSkills(
cwd: string,
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
): Promise<AgentSlashCommand[]> {
@@ -796,7 +808,7 @@ function filterCodexThreadsByCwd(
return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd));
}
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
export function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
const usage = toObjectRecord(tokenUsage);
if (!usage) return undefined;
const last = toObjectRecord(usage.last);
@@ -838,7 +850,7 @@ function normalizePlanMarkdown(text: string): string {
.trim();
}
function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
export function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
const lines = steps
.map((entry) => entry.step.trim())
.filter((step) => step.length > 0)
@@ -851,7 +863,7 @@ function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): st
return normalizePlanMarkdown(lines.join("\n"));
}
function mapCodexPlanToToolCall(params: {
export function mapCodexPlanToToolCall(params: {
callId: string;
text: string;
}): ToolCallTimelineItem | null {
@@ -935,7 +947,7 @@ interface CodexQuestionPrompt {
isSecret?: boolean;
}
function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
export function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
if (!Array.isArray(raw)) {
return [];
}
@@ -985,7 +997,7 @@ function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
return questions;
}
function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
export function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
return questions
.map((question) => {
const lines = [`${question.header}: ${question.question}`];
@@ -998,7 +1010,7 @@ function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
.trim();
}
function mapCodexQuestionRequestToToolCall(params: {
export function mapCodexQuestionRequestToToolCall(params: {
callId: string;
questions: CodexQuestionPrompt[];
status: ToolCallTimelineItem["status"];
@@ -1360,7 +1372,7 @@ function mapCodexExecNotificationToToolCall(params: {
? { exitCode: params.exitCode }
: {}),
};
const mapped = mapCodexRolloutToolCall({
const mapped = mapCodexToolCallEnvelope({
callId: params.callId ?? null,
name: "shell",
input: {
@@ -1377,7 +1389,7 @@ function mapCodexExecNotificationToToolCall(params: {
return params.running ? toRunningToolCall(mapped) : mapped;
}
function mapCodexPatchNotificationToToolCall(params: {
export function mapCodexPatchNotificationToToolCall(params: {
callId?: string | null;
changes: unknown;
cwd?: string | null;
@@ -1393,7 +1405,7 @@ function mapCodexPatchNotificationToToolCall(params: {
.find((value): value is string => typeof value === "string" && value.length > 0);
const patchText = firstPatchText;
const patchFields = codexPatchTextFields(patchText);
const mapped = mapCodexRolloutToolCall({
const mapped = mapCodexToolCallEnvelope({
callId: params.callId ?? null,
name: "apply_patch",
input: firstPath
@@ -1490,7 +1502,12 @@ function mapCodexThreadUserMessageItem(
return null;
}
const text = extractUserText(normalizedItem.content) ?? "";
return { type: "user_message", text };
const messageId = nonEmptyString(normalizedItem.id);
return {
type: "user_message",
text,
...(messageId ? { messageId } : {}),
};
}
function firstStringField(
@@ -1605,7 +1622,7 @@ function mapCodexThreadImageItem(
);
}
function threadItemToTimeline(
export function threadItemToTimeline(
item: unknown,
options?: { includeUserMessage?: boolean; cwd?: string | null },
): AgentTimelineItem | null {
@@ -1713,6 +1730,26 @@ function readCodexThread(client: CodexAppServerClientLike, threadId: string): Pr
});
}
export async function forkCodexThread(
client: CodexAppServerClientLike,
params: CodexThreadForkParams,
): Promise<CodexThreadForkResponse> {
if (client.forkThread) {
return client.forkThread(params);
}
return parseCodexThreadForkResponse(await client.request("thread/fork", params));
}
export async function rollbackCodexThread(
client: CodexAppServerClientLike,
params: CodexThreadRollbackParams,
): Promise<CodexThreadRollbackResponse> {
if (client.rollbackThread) {
return client.rollbackThread(params);
}
return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params));
}
function toSandboxPolicy(type: string, networkAccess?: boolean): Record<string, unknown> {
switch (type) {
case "read-only":
@@ -1999,6 +2036,18 @@ const CodexEventTurnDiffNotificationSchema = z
})
.passthrough();
const CodexEventThreadRolledBackNotificationSchema = z
.object({
msg: z
.object({
type: z.literal("thread_rolled_back"),
num_turns: z.number().int().nonnegative().optional(),
numTurns: z.number().int().nonnegative().optional(),
})
.passthrough(),
})
.passthrough();
type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string }
| { kind: "turn_started"; turnId: string; threadId: string | null }
@@ -2072,10 +2121,33 @@ type ParsedCodexNotification =
itemId: string;
delta: string | null;
}
| { kind: "thread_rolled_back"; numTurns: number }
| { kind: "context_compacted"; threadId: string; turnId: string | null }
| { kind: "invalid_payload"; method: string; params: unknown }
| { kind: "unknown_method"; method: string; params: unknown };
type CodexDeltaNotification = Extract<
ParsedCodexNotification,
{
kind:
| "agent_message_delta"
| "reasoning_delta"
| "exec_command_output_delta"
| "file_change_output_delta";
}
>;
function isCodexDeltaNotification(
parsed: ParsedCodexNotification,
): parsed is CodexDeltaNotification {
return (
parsed.kind === "agent_message_delta" ||
parsed.kind === "reasoning_delta" ||
parsed.kind === "exec_command_output_delta" ||
parsed.kind === "file_change_output_delta"
);
}
const CodexNotificationSchema = z.union([
z
.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema })
@@ -2548,6 +2620,24 @@ const CodexNotificationSchema = z.union([
params,
}),
),
z
.object({
method: z.literal("codex/event/thread_rolled_back"),
params: CodexEventThreadRolledBackNotificationSchema,
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "thread_rolled_back",
numTurns: params.msg.num_turns ?? params.msg.numTurns ?? 0,
}),
),
z.object({ method: z.literal("codex/event/thread_rolled_back"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.string(), params: z.unknown() })
.transform(
@@ -2685,7 +2775,7 @@ function toCodexTextInput(text: string): Extract<CodexAppServerUserInput, { type
};
}
function buildCodexAppServerEnv(
export function buildCodexAppServerEnv(
runtimeSettings?: ProviderRuntimeSettings,
launchEnv?: Record<string, string>,
): NodeJS.ProcessEnv {
@@ -2762,7 +2852,7 @@ interface CodexSubAgentCallState {
childItems: Map<string, AgentTimelineItem>;
}
class CodexAppServerAgentSession implements AgentSession {
export class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
@@ -2810,6 +2900,8 @@ class CodexAppServerAgentSession implements AgentSession {
private warnedIncompleteEditToolCallIds = new Set<string>();
private latestUsage: AgentUsage | undefined;
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
private readonly userMessageTurnIndexes = new Map<string, number>();
private readonly userMessageTurnIds: string[] = [];
private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
// Codex can report one completed compaction through both channels:
@@ -3135,20 +3227,22 @@ class CodexAppServerAgentSession implements AgentSession {
const client = this.client;
const threadId = this.currentThreadId;
try {
const timeline = await loadCodexThreadHistoryTimeline({
threadId,
cwd: this.config.cwd ?? null,
requestThread: (threadIdToRead) => {
return readCodexThread(client, threadIdToRead);
},
});
if (timeline.length > 0) {
this.persistedHistory = timeline;
this.historyPending = true;
const timeline = await loadCodexThreadHistoryTimeline({
threadId,
cwd: this.config.cwd ?? null,
requestThread: (threadIdToRead) => {
return readCodexThread(client, threadIdToRead);
},
});
this.resetCodexUserMessageTurns();
for (const entry of timeline) {
if (entry.item.type === "user_message") {
this.rememberCodexUserMessageTurn(entry.item.messageId);
}
} catch (error) {
this.logger.warn({ error }, "Failed to load Codex thread history");
}
if (timeline.length > 0) {
this.persistedHistory = timeline;
this.historyPending = true;
}
}
@@ -3431,6 +3525,41 @@ class CodexAppServerAgentSession implements AgentSession {
return { turnId };
}
private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean {
if (typeof messageId !== "string" || messageId.length === 0) {
return false;
}
if (this.userMessageTurnIndexes.has(messageId)) {
return false;
}
this.userMessageTurnIndexes.set(messageId, this.userMessageTurnIds.length);
this.userMessageTurnIds.push(messageId);
return true;
}
private resetCodexUserMessageTurns(): void {
this.userMessageTurnIndexes.clear();
this.userMessageTurnIds.length = 0;
}
private truncateCodexUserMessageTurns(numTurns: number): void {
if (numTurns <= 0) {
return;
}
this.userMessageTurnIds.length = Math.max(0, this.userMessageTurnIds.length - numTurns);
this.userMessageTurnIndexes.clear();
this.userMessageTurnIds.forEach((messageId, index) => {
this.userMessageTurnIndexes.set(messageId, index);
});
}
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
return {
resolve: (messageId) => this.userMessageTurnIndexes.get(messageId) ?? null,
count: () => this.userMessageTurnIds.length,
};
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
@@ -3714,6 +3843,35 @@ class CodexAppServerAgentSession implements AgentSession {
};
}
async revertConversation(input: { messageId: string }): Promise<void> {
await this.connect();
if (!this.client) {
throw new Error("Codex client is not initialized");
}
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
await revertCodexConversation({
client: this.client,
threadId: this.currentThreadId,
messageId: input.messageId,
cwd: this.config.cwd ?? null,
model: this.config.model ?? null,
serviceTier: this.serviceTier,
userMessageTurns: this.codexUserMessageTurns(),
setThreadId: async (threadId) => {
this.currentThreadId = threadId;
this.cachedRuntimeInfo = null;
this.persistedHistory = [];
this.historyPending = false;
await this.loadPersistedHistory();
},
});
}
async interrupt(): Promise<void> {
if (!this.client || !this.currentThreadId || !this.currentTurnId) return;
try {
@@ -4059,6 +4217,13 @@ class CodexAppServerAgentSession implements AgentSession {
private handleNotification(method: string, params: unknown): void {
const parsed = CodexNotificationSchema.parse({ method, params });
this.traceParsedNotification(method, params, parsed);
if (isCodexDeltaNotification(parsed)) {
this.handleCodexDeltaNotification(parsed);
return;
}
if (this.handleThreadStateNotification(parsed)) {
return;
}
switch (parsed.kind) {
case "thread_started":
this.handleThreadStartedNotification(parsed);
@@ -4080,15 +4245,6 @@ class CodexAppServerAgentSession implements AgentSession {
case "token_usage_updated":
this.handleTokenUsageUpdatedNotification(parsed);
return;
case "context_compacted":
this.handleContextCompactedNotification(parsed);
return;
case "agent_message_delta":
case "reasoning_delta":
case "exec_command_output_delta":
case "file_change_output_delta":
this.handleCodexDeltaNotification(parsed);
return;
case "exec_command_started":
this.handleExecCommandStartedNotification(parsed);
return;
@@ -4113,8 +4269,24 @@ class CodexAppServerAgentSession implements AgentSession {
case "invalid_payload":
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
return;
default:
case "unknown_method":
this.warnUnknownNotificationMethod(parsed.method, parsed.params);
return;
default:
return;
}
}
private handleThreadStateNotification(parsed: ParsedCodexNotification): boolean {
switch (parsed.kind) {
case "context_compacted":
this.handleContextCompactedNotification(parsed);
return true;
case "thread_rolled_back":
this.handleThreadRolledBackNotification(parsed);
return true;
default:
return false;
}
}
@@ -4264,18 +4436,7 @@ class CodexAppServerAgentSession implements AgentSession {
return Boolean(itemId && this.emittedItemCompletedIds.has(itemId));
}
private handleCodexDeltaNotification(
parsed: Extract<
ParsedCodexNotification,
{
kind:
| "agent_message_delta"
| "reasoning_delta"
| "exec_command_output_delta"
| "file_change_output_delta";
}
>,
): void {
private handleCodexDeltaNotification(parsed: CodexDeltaNotification): void {
if (parsed.kind === "agent_message_delta") {
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
const text = prev + parsed.delta;
@@ -4490,6 +4651,19 @@ class CodexAppServerAgentSession implements AgentSession {
);
}
private isUserMessageItem(item: { type?: string; [key: string]: unknown }): boolean {
return (
normalizeCodexThreadItemType(typeof item.type === "string" ? item.type : undefined) ===
"userMessage"
);
}
private handleThreadRolledBackNotification(
parsed: Extract<ParsedCodexNotification, { kind: "thread_rolled_back" }>,
): void {
this.truncateCodexUserMessageTurns(parsed.numTurns);
}
private handleContextCompactedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "context_compacted" }>,
): void {
@@ -4626,6 +4800,10 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.source === "codex_event") {
return;
}
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
return;
}
if (this.isContextCompactionItem(parsed.item)) {
if (this.unpairedCompactionNotificationCompletions > 0) {
this.unpairedCompactionNotificationCompletions -= 1;
@@ -4769,6 +4947,10 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.source === "codex_event") {
return;
}
if (this.isUserMessageItem(parsed.item)) {
this.handleUserMessageItem(parsed);
return;
}
if (this.isContextCompactionItem(parsed.item)) {
this.emitEvent({
type: "timeline",
@@ -4815,6 +4997,31 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
private handleUserMessageItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
const itemId = parsed.item.id;
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: true,
cwd: this.config.cwd ?? null,
});
if (!timelineItem || timelineItem.type !== "user_message") {
return;
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (childSubAgentCallId) {
if (itemId) {
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
}
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
return;
}
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId)) {
return;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
}
private warnUnknownNotificationMethod(method: string, params: unknown): void {
if (this.warnedUnknownNotificationMethods.has(method)) {
return;
@@ -5510,22 +5717,3 @@ function resolveSkillDescription(skill: Record<string, unknown>): string {
}
return "Skill";
}
export const __codexAppServerInternals = {
buildCodexAppServerEnv,
CodexAppServerClient,
codexModelSupportsFastMode,
CodexAppServerAgentSession,
findCodexMicrosoftStoreBinary,
findDefaultCodexBinary,
formatCodexQuestionPrompts,
mapCodexQuestionRequestToToolCall,
mapCodexPatchNotificationToToolCall,
planStepsToMarkdown,
mapCodexPlanToToolCall,
listCodexSkills,
normalizeCodexOutputSchema,
normalizeCodexQuestionPrompts,
toAgentUsage,
threadItemToTimeline,
};
@@ -1,547 +0,0 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { rmSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseRolloutFile } from "./codex-rollout-timeline.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "rollout-test-"));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("exec_command parsing", () => {
test("parses exec_command as Bash tool call with command", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:09:01.348Z",
type: "response_item",
payload: {
type: "function_call",
name: "exec_command",
arguments: '{"cmd":"task show cc4ea7d1"}',
call_id: "call_MhTWDF2mpM4dhbNmHNt6ikDF",
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
const toolCalls = timeline.filter((i) => i.type === "tool_call");
expect(toolCalls.length).toBe(1);
expect(toolCalls[0]).toMatchObject({
type: "tool_call",
name: "Bash",
callId: "call_MhTWDF2mpM4dhbNmHNt6ikDF",
detail: { type: "shell", command: "task show cc4ea7d1" },
});
});
test("includes function_call_output as tool call output", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:09:01.348Z",
type: "response_item",
payload: {
type: "function_call",
name: "exec_command",
arguments: '{"cmd":"echo hello"}',
call_id: "call_abc123",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:09:01.785Z",
type: "response_item",
payload: {
type: "function_call_output",
call_id: "call_abc123",
output:
"Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nhello",
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
const toolCalls = timeline.filter((i) => i.type === "tool_call");
expect(toolCalls.length).toBe(1);
expect(toolCalls[0]).toMatchObject({
type: "tool_call",
name: "Bash",
callId: "call_abc123",
detail: {
type: "shell",
command: "echo hello",
output: "hello",
},
});
});
test("maps write_stdin polling calls into terminal interaction tool calls", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:09:01.348Z",
type: "response_item",
payload: {
type: "function_call",
name: "exec_command",
arguments: '{"cmd":"npm test"}',
call_id: "call_real",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:09:01.785Z",
type: "response_item",
payload: {
type: "function_call_output",
call_id: "call_real",
output:
"Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nProcess running with session ID 7144",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:28:16.497Z",
type: "response_item",
payload: {
type: "function_call",
name: "write_stdin",
arguments: '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}',
call_id: "call_polling",
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
const toolCalls = timeline.filter((i) => i.type === "tool_call");
expect(toolCalls.length).toBe(2);
expect(toolCalls[0]).toMatchObject({
type: "tool_call",
name: "Bash",
});
expect(toolCalls[1]).toMatchObject({
type: "tool_call",
name: "terminal",
callId: "terminal-session-7144",
detail: {
type: "plain_text",
icon: "square_terminal",
},
metadata: {
processId: "7144",
},
});
});
test("falls back to generic terminal interaction label when command cannot be resolved", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:28:16.497Z",
type: "response_item",
payload: {
type: "function_call",
name: "write_stdin",
arguments: '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}',
call_id: "call_polling",
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
const toolCalls = timeline.filter((i) => i.type === "tool_call");
expect(toolCalls).toHaveLength(1);
expect(toolCalls[0]).toMatchObject({
type: "tool_call",
name: "terminal",
callId: "terminal-session-7144",
detail: {
type: "plain_text",
icon: "square_terminal",
},
});
});
});
describe("older shell format", () => {
test("parses shell command as Bash tool call", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2025-11-03T14:37:50.400Z",
type: "response_item",
payload: {
type: "function_call",
name: "shell",
arguments: '{"command":["bash","-lc","ls -la"],"workdir":"/Users/test/project"}',
call_id: "call_shell123",
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
const toolCalls = timeline.filter((i) => i.type === "tool_call");
expect(toolCalls.length).toBe(1);
expect(toolCalls[0]).toMatchObject({
type: "tool_call",
name: "Bash",
callId: "call_shell123",
detail: { type: "shell", command: "ls -la" },
});
});
});
describe("real rollout file structure", () => {
test("parses user message correctly", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "response_item",
payload: {
type: "message",
role: "user",
content: [{ type: "input_text", text: "Fix the bug in auth.ts" }],
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
expect(timeline).toContainEqual({
type: "user_message",
text: "Fix the bug in auth.ts",
});
});
test("parses assistant message correctly", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "response_item",
payload: {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "I'll fix that for you." }],
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
expect(timeline).toContainEqual({
type: "assistant_message",
text: "I'll fix that for you.",
});
});
test("parses reasoning correctly", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "response_item",
payload: {
type: "reasoning",
content: [{ type: "reasoning_text", text: "Let me think about this." }],
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
expect(timeline).toContainEqual({
type: "reasoning",
text: "Let me think about this.",
});
});
test("parses legacy response_item shape using item", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "response_item",
item: {
type: "function_call",
name: "exec_command",
arguments: '{"cmd":"echo hello"}',
call_id: "call_legacy_1",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:09:01.785Z",
type: "response_item",
item: {
type: "function_call_output",
call_id: "call_legacy_1",
output: "hello",
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
const toolCalls = timeline.filter((i) => i.type === "tool_call");
expect(toolCalls.length).toBe(1);
expect(toolCalls[0]).toMatchObject({
type: "tool_call",
name: "Bash",
callId: "call_legacy_1",
detail: { type: "shell", command: "echo hello", output: "hello" },
});
});
test("parses custom_tool_call apply_patch with input/output into editable tool detail", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const patch = [
"*** Begin Patch",
"*** Add File: src/new-file.ts",
"+export const value = 1;",
"*** End Patch",
].join("\n");
const lines = [
JSON.stringify({
timestamp: "2026-02-09T10:00:00.000Z",
type: "response_item",
payload: {
type: "custom_tool_call",
name: "apply_patch",
call_id: "call_patch_custom_1",
input: patch,
},
}),
JSON.stringify({
timestamp: "2026-02-09T10:00:01.000Z",
type: "response_item",
payload: {
type: "custom_tool_call_output",
call_id: "call_patch_custom_1",
output:
'{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}',
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
const toolCalls = timeline.filter((i) => i.type === "tool_call");
expect(toolCalls.length).toBe(1);
const patchCall = toolCalls[0];
expect(patchCall).toMatchObject({
type: "tool_call",
name: "apply_patch",
callId: "call_patch_custom_1",
status: "completed",
});
expect(patchCall.detail.type).toBe("edit");
if (patchCall.detail.type === "edit") {
expect(patchCall.detail.filePath).toBe("src/new-file.ts");
expect(patchCall.detail.unifiedDiff).toContain("diff --git");
expect(patchCall.detail.unifiedDiff).toContain("+export const value = 1;");
expect(patchCall.detail.unifiedDiff).not.toContain("*** Begin Patch");
}
});
test("parses legacy event_msg shape using msg", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "event_msg",
msg: {
type: "agent_reasoning",
text: "thinking",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "event_msg",
msg: {
type: "agent_message",
message: { role: "assistant", message: "done" },
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "event_msg",
msg: {
type: "user_message",
message: { role: "user", message: "question" },
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
expect(timeline).toContainEqual({ type: "reasoning", text: "thinking" });
expect(timeline).toContainEqual({ type: "assistant_message", text: "done" });
expect(timeline).toContainEqual({ type: "user_message", text: "question" });
});
test("deduplicates mirrored response_item and event_msg text records", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
JSON.stringify({
timestamp: "2026-01-22T07:08:54.000Z",
type: "response_item",
payload: {
type: "message",
role: "user",
content: [{ type: "input_text", text: "question" }],
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.001Z",
type: "event_msg",
payload: {
type: "user_message",
message: "question",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.010Z",
type: "response_item",
payload: {
type: "reasoning",
content: [{ type: "reasoning_text", text: "thinking" }],
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.011Z",
type: "event_msg",
payload: {
type: "agent_reasoning",
text: "thinking",
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.020Z",
type: "response_item",
payload: {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "answer" }],
},
}),
JSON.stringify({
timestamp: "2026-01-22T07:08:54.021Z",
type: "event_msg",
payload: {
type: "agent_message",
message: "answer",
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
expect(timeline).toEqual([
{ type: "user_message", text: "question" },
{ type: "reasoning", text: "thinking" },
{ type: "assistant_message", text: "answer" },
]);
});
});
describe("complex conversation", () => {
test("parses a full conversation with commands and outputs", async () => {
const rolloutPath = join(tmpDir, "rollout.jsonl");
const lines = [
// User message
JSON.stringify({
timestamp: "2026-01-22T07:08:54.378Z",
type: "response_item",
payload: {
type: "message",
role: "user",
content: [{ type: "input_text", text: "Run npm test" }],
},
}),
// Reasoning
JSON.stringify({
timestamp: "2026-01-22T07:08:55.000Z",
type: "response_item",
payload: {
type: "reasoning",
content: [{ type: "reasoning_text", text: "I need to run the tests." }],
},
}),
// Command
JSON.stringify({
timestamp: "2026-01-22T07:09:01.348Z",
type: "response_item",
payload: {
type: "function_call",
name: "exec_command",
arguments: '{"cmd":"npm test"}',
call_id: "call_test",
},
}),
// Command output
JSON.stringify({
timestamp: "2026-01-22T07:09:05.000Z",
type: "response_item",
payload: {
type: "function_call_output",
call_id: "call_test",
output:
"Chunk ID: abc\nWall time: 3.5 seconds\nProcess exited with code 0\nOutput:\nAll tests passed!",
},
}),
// Assistant response
JSON.stringify({
timestamp: "2026-01-22T07:09:06.000Z",
type: "response_item",
payload: {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "All tests passed!" }],
},
}),
];
writeFileSync(rolloutPath, lines.join("\n") + "\n");
const timeline = await parseRolloutFile(rolloutPath);
// Should have: user_message, reasoning, tool_call (with output), assistant_message
expect(timeline.length).toBe(4);
expect(timeline[0]).toMatchObject({ type: "user_message", text: "Run npm test" });
expect(timeline[1]).toMatchObject({
type: "reasoning",
text: "I need to run the tests.",
});
expect(timeline[2]).toMatchObject({
type: "tool_call",
name: "Bash",
detail: {
type: "shell",
command: "npm test",
output: "All tests passed!",
},
});
expect(timeline[3]).toMatchObject({
type: "assistant_message",
text: "All tests passed!",
});
});
});
@@ -1,641 +0,0 @@
import { promises as fs, type Dirent } from "node:fs";
import os from "node:os";
import path from "node:path";
import { z } from "zod";
import type { Logger } from "pino";
import type { AgentTimelineItem } from "../agent-sdk-types.js";
import { mapCodexRolloutToolCall } from "./codex/tool-call-mapper.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
const MAX_ROLLOUT_SEARCH_DEPTH = 4;
function resolveCodexSessionRoot(): string | null {
if (process.env.CODEX_SESSION_DIR) {
return process.env.CODEX_SESSION_DIR;
}
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
return path.join(codexHome, "sessions");
}
async function findRolloutFile(threadId: string, root: string): Promise<string | null> {
let currentLevel: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
while (currentLevel.length > 0) {
const readings = await Promise.all(
currentLevel.map(async ({ dir, depth }) => {
let entries: Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return { match: null as string | null, next: [] as { dir: string; depth: number }[] };
}
let match: string | null = null;
const next: { dir: string; depth: number }[] = [];
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isFile()) {
const matchesThread = entry.name.includes(threadId);
const matchesPrefix = entry.name.startsWith("rollout-");
const matchesExtension = entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
if (matchesThread && matchesPrefix && matchesExtension && match === null) {
match = entryPath;
}
} else if (entry.isDirectory() && depth < MAX_ROLLOUT_SEARCH_DEPTH) {
next.push({ dir: entryPath, depth: depth + 1 });
}
}
return { match, next };
}),
);
const firstMatch = readings.find((reading) => reading.match !== null);
if (firstMatch?.match) {
return firstMatch.match;
}
currentLevel = readings.flatMap((reading) => reading.next);
}
return null;
}
const RolloutContentItemSchema = z.union([
z.object({ type: z.literal("input_text"), text: z.string() }),
z.object({ type: z.literal("output_text"), text: z.string() }),
z.object({ type: z.literal("reasoning_text"), text: z.string() }),
z.object({ type: z.literal("text"), text: z.string() }),
z
.object({
type: z.string(),
text: z.string().optional(),
message: z.string().optional(),
})
.passthrough(),
]);
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
function extractContentTextByType(content: unknown, itemType: string): string {
const parsed = RolloutContentArraySchema.safeParse(content);
if (!parsed.success) {
return "";
}
return parsed.data
.filter((item) => item.type === itemType)
.map((item) => item.text)
.join("\n")
.trim();
}
const RolloutResponseMessagePayloadSchema = z.object({
type: z.literal("message"),
role: z.enum(["user", "assistant"]).optional(),
content: z.unknown().optional(),
});
const RolloutResponseReasoningPayloadSchema = z.object({
type: z.literal("reasoning"),
content: z.unknown().optional(),
summary: z.array(z.object({ text: z.string().optional() })).optional(),
text: z.string().optional(),
});
const RolloutResponseFunctionCallPayloadSchema = z.object({
type: z.literal("function_call"),
name: z.string().optional(),
call_id: z.string().optional(),
arguments: z.unknown().optional(),
});
const RolloutResponseCustomToolCallPayloadSchema = z
.object({
type: z.literal("custom_tool_call"),
name: z.string().optional(),
call_id: z.string().optional(),
arguments: z.unknown().optional(),
input: z.unknown().optional(),
})
.passthrough();
const RolloutResponseFunctionCallOutputPayloadSchema = z
.object({
type: z.literal("function_call_output"),
call_id: z.string().optional(),
output: z.unknown().optional(),
})
.passthrough();
const RolloutResponseCustomToolCallOutputPayloadSchema = z
.object({
type: z.literal("custom_tool_call_output"),
call_id: z.string().optional(),
output: z.unknown().optional(),
})
.passthrough();
const RolloutEventAgentReasoningPayloadSchema = z.object({
type: z.literal("agent_reasoning"),
text: z.string().optional(),
});
const RolloutEventAgentMessagePayloadSchema = z.object({
type: z.literal("agent_message"),
message: z
.union([
z.string(),
z
.object({
role: z.string().optional(),
message: z.string().optional(),
text: z.string().optional(),
})
.passthrough(),
])
.optional(),
});
const RolloutEventUserMessagePayloadSchema = z.object({
type: z.literal("user_message"),
message: z
.union([
z.string(),
z
.object({
role: z.string().optional(),
message: z.string().optional(),
text: z.string().optional(),
})
.passthrough(),
])
.optional(),
});
type RolloutResponseReasoningPayload = z.infer<typeof RolloutResponseReasoningPayloadSchema>;
type ParsedRolloutRecord =
| { kind: "timeline"; item: AgentTimelineItem }
| { kind: "call"; name: string; callId?: string; input?: unknown }
| { kind: "output"; callId: string; output: unknown }
| { kind: "ignore" };
const RolloutMessageContentSchema = z.union([
z.string().transform((content) => content.trim()),
z
.array(
z
.object({
text: z.string().optional(),
message: z.string().optional(),
})
.passthrough(),
)
.transform((content) =>
content
.map((block) => block.text ?? block.message ?? "")
.map((text) => text.trim())
.filter(Boolean)
.join("\n")
.trim(),
),
]);
function extractMessageText(content: unknown): string {
const parsed = RolloutMessageContentSchema.safeParse(content);
if (!parsed.success) {
return "";
}
return parsed.data;
}
const RolloutEventMessageTextSchema = z.union([
z.string(),
z
.object({
message: z.string().optional(),
text: z.string().optional(),
})
.passthrough()
.transform((message) => message.message ?? message.text ?? ""),
]);
function extractEventMessageText(message: unknown): string {
const parsed = RolloutEventMessageTextSchema.safeParse(message);
if (!parsed.success) {
return "";
}
return parsed.data;
}
function isSyntheticRolloutUserMessage(text: string): boolean {
const normalized = text.trim();
if (!normalized) {
return false;
}
const lower = normalized.toLowerCase();
if (lower.startsWith("# agents.md instructions for") && lower.includes("<instructions>")) {
return true;
}
if (lower.startsWith("<environment_context>")) {
return true;
}
return false;
}
function extractReasoningText(payload: RolloutResponseReasoningPayload): string {
if (Array.isArray(payload.summary)) {
const text = payload.summary
.map((item) => item.text ?? "")
.filter(Boolean)
.join("\n")
.trim();
if (text) {
return text;
}
}
const contentText = extractContentTextByType(payload.content, "reasoning_text");
if (contentText) {
return contentText;
}
if (typeof payload.text === "string") {
return payload.text;
}
return "";
}
function parseJsonLikeString(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
function parseJsonLikeValue(value: unknown): unknown {
if (typeof value === "string") {
return parseJsonLikeString(value);
}
return value;
}
function readTerminalSessionId(value: unknown): string | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
if (typeof value === "string") {
return nonEmptyString(value.trim());
}
return undefined;
}
function mapCodexTerminalInteractionToToolCall(params: {
processId?: string | undefined;
fallbackCallId?: string | undefined;
command?: string | undefined;
}): Extract<AgentTimelineItem, { type: "tool_call" }> {
const processId = nonEmptyString(params.processId);
const callId = processId
? `terminal-session-${processId}`
: (nonEmptyString(params.fallbackCallId) ?? "terminal-interaction");
const label = nonEmptyString(params.command);
return {
type: "tool_call",
callId,
name: "terminal",
status: "completed",
error: null,
detail: {
type: "plain_text",
...(label ? { label } : {}),
icon: "square_terminal",
},
metadata: processId ? { processId } : undefined,
};
}
function buildTerminalCommandBySessionId(
parsedRecords: ParsedRolloutRecord[],
): Map<string, string> {
const outputsByCallId = parsedRecords
.filter(
(record): record is Extract<ParsedRolloutRecord, { kind: "output" }> =>
record.kind === "output",
)
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
const commandsBySessionId = new Map<string, string>();
for (const record of parsedRecords) {
if (record.kind !== "call" || record.name === "write_stdin") {
continue;
}
const mapped = mapCodexRolloutToolCall({
callId: record.callId ?? null,
name: record.name,
input: record.input ?? null,
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
});
if (!mapped || mapped.detail.type !== "shell") {
continue;
}
const sessionId = extractCodexTerminalSessionId(mapped.detail.output);
if (!sessionId) {
continue;
}
commandsBySessionId.set(sessionId, mapped.detail.command);
}
return commandsBySessionId;
}
function readOutputPayloadValue(payload: Record<string, unknown>): unknown {
if (payload.output !== undefined) {
return parseJsonLikeValue(payload.output);
}
const { type: _type, call_id: _callId, ...rest } = payload;
return Object.keys(rest).length > 0 ? rest : undefined;
}
const FunctionCallInputNormalizationSchema = z.union([
z
.object({ cmd: z.string() })
.transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
z
.object({ command: z.array(z.string()) })
.transform((input) => ({ name: "Bash", input: { command: input.command[2] ?? "" } })),
z.unknown().transform((input) => ({ name: "unknown", input })),
]);
const RolloutResponseRecordSchema = z.union([
RolloutResponseMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractMessageText(payload.content);
const itemType = payload.role === "assistant" ? "assistant_message" : "user_message";
const shouldEmit =
text.length > 0 && (itemType !== "user_message" || !isSyntheticRolloutUserMessage(text));
return shouldEmit ? { kind: "timeline", item: { type: itemType, text } } : { kind: "ignore" };
}),
RolloutResponseReasoningPayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractReasoningText(payload);
return text.length > 0
? { kind: "timeline", item: { type: "reasoning", text } }
: { kind: "ignore" };
}),
z
.union([RolloutResponseFunctionCallPayloadSchema, RolloutResponseCustomToolCallPayloadSchema])
.transform((payload): ParsedRolloutRecord => {
const rawName = payload.name ?? "unknown";
const rawInput = payload.arguments ?? ("input" in payload ? payload.input : undefined);
const parsedArguments = parseJsonLikeValue(rawInput);
const normalized =
rawName === "exec_command" || rawName === "shell"
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
: { name: rawName, input: parsedArguments };
return {
kind: "call",
name: normalized.name,
callId: payload.call_id,
input: normalized.input,
};
}),
z
.union([
RolloutResponseFunctionCallOutputPayloadSchema,
RolloutResponseCustomToolCallOutputPayloadSchema,
])
.transform((payload): ParsedRolloutRecord => {
if (!payload.call_id) {
return { kind: "ignore" };
}
const output = readOutputPayloadValue(payload);
return output !== undefined
? { kind: "output", callId: payload.call_id, output }
: { kind: "ignore" };
}),
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]);
const RolloutEventRecordSchema = z.union([
RolloutEventAgentReasoningPayloadSchema.transform(
(payload): ParsedRolloutRecord =>
payload.text
? { kind: "timeline", item: { type: "reasoning", text: payload.text } }
: { kind: "ignore" },
),
RolloutEventAgentMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractEventMessageText(payload.message);
return text.length > 0
? { kind: "timeline", item: { type: "assistant_message", text } }
: { kind: "ignore" };
}),
RolloutEventUserMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
const text = extractEventMessageText(payload.message);
const shouldEmit = text.length > 0 && !isSyntheticRolloutUserMessage(text);
return shouldEmit
? { kind: "timeline", item: { type: "user_message", text } }
: { kind: "ignore" };
}),
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
]);
const RolloutRecordSchema = z
.object({
type: z.enum(["response_item", "event_msg"]),
payload: z.unknown().optional(),
item: z.unknown().optional(),
msg: z.unknown().optional(),
})
.transform((entry) =>
entry.type === "response_item"
? RolloutResponseRecordSchema.parse(entry.payload ?? entry.item)
: RolloutEventRecordSchema.parse(entry.payload ?? entry.msg),
);
function parseJsonRolloutTimeline(parsed: unknown): AgentTimelineItem[] | null {
if (!parsed || typeof parsed !== "object") {
return null;
}
const items = (parsed as { items?: unknown }).items;
if (!Array.isArray(items)) {
return null;
}
const timeline: AgentTimelineItem[] = [];
for (const entry of items) {
if (!entry || typeof entry !== "object") {
continue;
}
const messagePayloadResult = RolloutResponseMessagePayloadSchema.safeParse(entry);
if (messagePayloadResult.success) {
const payload = messagePayloadResult.data;
const text = extractMessageText(payload.content);
if (!text) {
continue;
}
if (payload.role === "assistant") {
timeline.push({ type: "assistant_message", text });
} else if (payload.role === "user") {
if (!isSyntheticRolloutUserMessage(text)) {
timeline.push({ type: "user_message", text });
}
}
continue;
}
const reasoningPayloadResult = RolloutResponseReasoningPayloadSchema.safeParse(entry);
if (reasoningPayloadResult.success) {
const text = extractReasoningText(reasoningPayloadResult.data);
if (text) {
timeline.push({ type: "reasoning", text });
}
continue;
}
}
return timeline;
}
function timelineTextFingerprint(item: AgentTimelineItem): string | null {
switch (item.type) {
case "user_message":
case "assistant_message":
case "reasoning":
return `${item.type}\u0000${item.text}`;
default:
return null;
}
}
function dedupeMirroredTextTimelineItems(timeline: AgentTimelineItem[]): AgentTimelineItem[] {
const deduped: AgentTimelineItem[] = [];
for (const item of timeline) {
const prev = deduped[deduped.length - 1];
if (!prev) {
deduped.push(item);
continue;
}
const currentFingerprint = timelineTextFingerprint(item);
const previousFingerprint = timelineTextFingerprint(prev);
if (currentFingerprint && previousFingerprint && currentFingerprint === previousFingerprint) {
continue;
}
deduped.push(item);
}
return deduped;
}
export async function parseRolloutFile(filePath: string): Promise<AgentTimelineItem[]> {
const content = await fs.readFile(filePath, "utf8");
const trimmed = content.trim();
if (!trimmed) {
return [];
}
try {
const parsed = JSON.parse(trimmed);
const jsonTimeline = parseJsonRolloutTimeline(parsed);
if (jsonTimeline) {
return dedupeMirroredTextTimelineItems(jsonTimeline);
}
} catch {
// Fall back to JSONL parsing.
}
const lines = trimmed
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const parsedRecords = lines
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter((record): record is unknown => record !== null)
.map((record) => RolloutRecordSchema.safeParse(record))
.filter((result): result is { success: true; data: ParsedRolloutRecord } => result.success)
.map((result) => result.data);
const outputsByCallId = parsedRecords
.filter(
(record): record is Extract<ParsedRolloutRecord, { kind: "output" }> =>
record.kind === "output",
)
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
const terminalCommandsBySessionId = buildTerminalCommandBySessionId(parsedRecords);
const timeline = parsedRecords.flatMap((record): AgentTimelineItem[] => {
if (record.kind === "timeline") {
return [record.item];
}
if (record.kind !== "call") {
return [];
}
if (record.name === "write_stdin") {
const input =
record.input && typeof record.input === "object"
? (record.input as { session_id?: unknown; sessionId?: unknown })
: null;
const sessionId =
readTerminalSessionId(input?.session_id) ?? readTerminalSessionId(input?.sessionId);
return [
mapCodexTerminalInteractionToToolCall({
processId: sessionId,
fallbackCallId: record.callId,
command: sessionId ? terminalCommandsBySessionId.get(sessionId) : undefined,
}),
];
}
const mapped = mapCodexRolloutToolCall({
callId: record.callId ?? null,
name: record.name,
input: record.input ?? null,
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
});
return mapped ? [mapped] : [];
});
return dedupeMirroredTextTimelineItems(timeline);
}
export interface CodexPersistedTimelineOptions {
sessionRoot?: string | null;
rolloutPath?: string | null;
}
export async function loadCodexPersistedTimeline(
sessionId: string,
options?: CodexPersistedTimelineOptions,
logger?: Logger,
): Promise<AgentTimelineItem[]> {
const rolloutPath = options?.rolloutPath ?? null;
if (rolloutPath) {
try {
const stat = await fs.stat(rolloutPath);
if (stat.isFile()) {
const timeline = await parseRolloutFile(rolloutPath);
if (timeline.length > 0) {
return timeline;
}
}
} catch {
// Fall back to session root scan.
}
}
try {
const preferredRoot = options?.sessionRoot ?? resolveCodexSessionRoot();
const fallbackRoot = resolveCodexSessionRoot();
let rolloutFile: string | null = null;
if (preferredRoot) {
rolloutFile = await findRolloutFile(sessionId, preferredRoot);
}
if (!rolloutFile && fallbackRoot && fallbackRoot !== preferredRoot) {
rolloutFile = await findRolloutFile(sessionId, fallbackRoot);
}
if (!rolloutFile) {
return [];
}
return await parseRolloutFile(rolloutFile);
} catch (error) {
logger?.warn({ err: error, sessionId }, "Failed to load persisted timeline");
return [];
}
}
@@ -1,7 +1,10 @@
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
import {
createCodexAppServerChildProcess,
createFakeCodexAppServer,
} from "./test-utils/fake-app-server.js";
import { CodexAppServerClient } from "./app-server-transport.js";
describe("Codex app-server transport", () => {
@@ -25,23 +28,87 @@ describe("Codex app-server transport", () => {
"item/tool/requestUserInput",
"tool/requestUserInput",
])("answers server-initiated %s requests through registered handlers", async (method) => {
const child = createCodexAppServerChildProcess();
const client = new CodexAppServerClient(child, createTestLogger());
const codex = createFakeCodexAppServer();
const client = new CodexAppServerClient(codex.child, createTestLogger());
const handlerCalls: unknown[] = [];
client.setRequestHandler(method, async (params) => {
handlerCalls.push(params);
return { ok: true };
});
const response = new Promise<string>((resolve) => {
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
});
child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
const response = codex.nextResponse();
codex.child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
expect(handlerCalls).toEqual([{}]);
child.stdout.end();
child.stderr.end();
child.stdin.end();
codex.child.stdout.end();
codex.child.stderr.end();
codex.child.stdin.end();
});
test("forks a Codex thread through thread/fork", async () => {
const codex = createFakeCodexAppServer({
"thread/fork": (params) => ({
thread: {
id: "forked-thread",
sessionId: "forked-session",
forkedFromId: (params as { threadId?: string }).threadId,
turns: [],
},
model: "gpt-5.4",
modelProvider: "openai",
serviceTier: null,
cwd: "/workspace/project",
runtimeWorkspaceRoots: [],
instructionSources: [],
approvalPolicy: "on-request",
approvalsReviewer: null,
sandbox: { type: "workspaceWrite", networkAccess: false },
activePermissionProfile: null,
reasoningEffort: null,
}),
});
const client = new CodexAppServerClient(codex.child, createTestLogger());
const forked = await client.forkThread({
threadId: "source-thread",
cwd: "/workspace/project",
excludeTurns: true,
});
expect(forked.thread.id).toBe("forked-thread");
expect(forked.thread.forkedFromId).toBe("source-thread");
codex.assertNoErrors();
codex.child.stdout.end();
codex.child.stderr.end();
codex.child.stdin.end();
});
test("rolls back a Codex thread by N turns", async () => {
const codex = createFakeCodexAppServer({
"thread/rollback": (params) => {
expect(params).toEqual({ threadId: "forked-thread", numTurns: 2 });
return {
thread: {
id: "forked-thread",
sessionId: "forked-session",
turns: [{ id: "remaining-turn" }],
},
};
},
});
const client = new CodexAppServerClient(codex.child, createTestLogger());
const rolledBack = await client.rollbackThread({
threadId: "forked-thread",
numTurns: 2,
});
expect(rolledBack.thread.id).toBe("forked-thread");
expect(rolledBack.thread.turns).toEqual([{ id: "remaining-turn" }]);
codex.assertNoErrors();
codex.child.stdout.end();
codex.child.stderr.end();
codex.child.stdin.end();
});
});
@@ -1,6 +1,7 @@
import type { ChildProcessWithoutNullStreams } from "node:child_process";
import readline from "node:readline";
import type { Logger } from "pino";
import { z } from "zod";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
@@ -35,6 +36,81 @@ interface PendingRequest {
type RequestHandler = (params: unknown) => unknown;
type NotificationHandler = (method: string, params: unknown) => void;
export interface CodexThreadForkParams {
threadId: string;
path?: string | null;
model?: string | null;
modelProvider?: string | null;
serviceTier?: string | null;
cwd?: string | null;
runtimeWorkspaceRoots?: string[] | null;
approvalPolicy?: unknown;
approvalsReviewer?: unknown;
sandbox?: unknown;
permissions?: string | null;
config?: Record<string, unknown> | null;
baseInstructions?: string | null;
developerInstructions?: string | null;
ephemeral?: boolean;
threadSource?: unknown;
excludeTurns?: boolean;
persistExtendedHistory?: boolean;
}
const CodexThreadForkResponseSchema = z
.object({
thread: z
.object({
id: z.string(),
sessionId: z.string().optional(),
forkedFromId: z.string().nullable().optional(),
turns: z.array(z.unknown()).optional(),
})
.passthrough(),
model: z.string(),
modelProvider: z.string(),
serviceTier: z.string().nullable(),
cwd: z.string(),
runtimeWorkspaceRoots: z.array(z.string()).optional().default([]),
instructionSources: z.array(z.string()).optional().default([]),
approvalPolicy: z.unknown(),
approvalsReviewer: z.unknown(),
sandbox: z.unknown(),
activePermissionProfile: z.unknown().optional(),
reasoningEffort: z.string().nullable().optional(),
})
.passthrough();
export type CodexThreadForkResponse = z.infer<typeof CodexThreadForkResponseSchema>;
export function parseCodexThreadForkResponse(response: unknown): CodexThreadForkResponse {
return CodexThreadForkResponseSchema.parse(response);
}
export interface CodexThreadRollbackParams {
threadId: string;
numTurns: number;
}
const CodexThreadRollbackResponseSchema = z
.object({
thread: z
.object({
id: z.string(),
sessionId: z.string().optional(),
forkedFromId: z.string().nullable().optional(),
turns: z.array(z.unknown()).optional(),
})
.passthrough(),
})
.passthrough();
export type CodexThreadRollbackResponse = z.infer<typeof CodexThreadRollbackResponseSchema>;
export function parseCodexThreadRollbackResponse(response: unknown): CodexThreadRollbackResponse {
return CodexThreadRollbackResponseSchema.parse(response);
}
export interface CodexAppServerTraceContext {
agentId?: string;
sessionId?: string;
@@ -156,6 +232,14 @@ export class CodexAppServerClient {
});
}
async forkThread(params: CodexThreadForkParams): Promise<CodexThreadForkResponse> {
return parseCodexThreadForkResponse(await this.request("thread/fork", params));
}
async rollbackThread(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse> {
return parseCodexThreadRollbackResponse(await this.request("thread/rollback", params));
}
notify(method: string, params?: unknown): void {
if (this.disposed) {
return;
@@ -0,0 +1,150 @@
import { describe, expect, test } from "vitest";
import type {
CodexThreadForkParams,
CodexThreadForkResponse,
CodexThreadRollbackParams,
CodexThreadRollbackResponse,
} from "./app-server-transport.js";
import {
type CodexUserMessageTurnIndex,
type CodexRewindClient,
revertCodexConversation,
} from "./rewind.js";
class FakeCodex implements CodexRewindClient {
readonly recordedForks: CodexThreadForkParams[] = [];
readonly recordedRollbacks: CodexThreadRollbackParams[] = [];
async forkThread(params: CodexThreadForkParams): Promise<CodexThreadForkResponse> {
this.recordedForks.push(params);
return {
thread: {
id: "forked-thread",
sessionId: "forked-session",
forkedFromId: params.threadId,
turns: [],
},
model: "gpt-5.4-mini",
modelProvider: "openai",
serviceTier: null,
cwd: "/workspace/project",
runtimeWorkspaceRoots: [],
instructionSources: [],
approvalPolicy: "on-request",
approvalsReviewer: null,
sandbox: { type: "workspaceWrite", networkAccess: false },
activePermissionProfile: null,
reasoningEffort: null,
};
}
async rollbackThread(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse> {
this.recordedRollbacks.push(params);
return {
thread: {
id: params.threadId,
sessionId: "forked-session",
forkedFromId: "source-thread",
turns: [],
},
};
}
request(): Promise<unknown> {
throw new Error("FakeCodex uses typed thread methods");
}
}
class CodexMessageTurns implements CodexUserMessageTurnIndex {
constructor(private readonly indexesByMessageId: Map<string, number>) {}
resolve(messageId: string): number | null {
return this.indexesByMessageId.get(messageId) ?? null;
}
count(): number {
return this.indexesByMessageId.size;
}
}
describe("Codex Rewind", () => {
test("rewinds the conversation by forking the thread and rolling back past the native user message", async () => {
const codex = new FakeCodex();
const userMessageTurns = new CodexMessageTurns(
new Map([
["codex-first", 0],
["codex-second", 1],
]),
);
let reboundThreadId: string | null = null;
await revertCodexConversation({
client: codex,
threadId: "source-thread",
messageId: "codex-first",
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
userMessageTurns,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
});
expect(codex.recordedForks).toEqual([
{
threadId: "source-thread",
cwd: "/workspace/project",
model: "gpt-5.4-mini",
serviceTier: null,
excludeTurns: false,
persistExtendedHistory: true,
},
]);
expect(codex.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 2 }]);
expect(reboundThreadId).toBe("forked-thread");
});
test("rewinds the conversation using native user message ids hydrated from app-server history", async () => {
const codex = new FakeCodex();
const userMessageTurns = new CodexMessageTurns(
new Map([
["codex-first", 0],
["codex-second", 1],
["codex-third", 2],
]),
);
let reboundThreadId: string | null = null;
await revertCodexConversation({
client: codex,
threadId: "source-thread",
messageId: "codex-second",
userMessageTurns,
setThreadId: (threadId) => {
reboundThreadId = threadId;
},
});
expect(codex.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 2 }]);
expect(reboundThreadId).toBe("forked-thread");
});
test("declines to rewind when the user message is not in the Codex thread", async () => {
const codex = new FakeCodex();
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
await expect(
revertCodexConversation({
client: codex,
threadId: "source-thread",
messageId: "missing-message",
userMessageTurns,
setThreadId: () => undefined,
}),
).rejects.toThrow("Codex could not find user message missing-message");
expect(codex.recordedForks).toEqual([]);
expect(codex.recordedRollbacks).toEqual([]);
});
});
@@ -0,0 +1,87 @@
import type {
CodexThreadForkParams,
CodexThreadForkResponse,
CodexThreadRollbackParams,
CodexThreadRollbackResponse,
} from "./app-server-transport.js";
import {
parseCodexThreadForkResponse,
parseCodexThreadRollbackResponse,
} from "./app-server-transport.js";
export interface CodexRewindClient {
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
rollbackThread?(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse>;
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
}
export interface CodexUserMessageTurnIndex {
resolve(messageId: string): number | null;
count(): number;
}
async function forkCodexThread(
client: CodexRewindClient,
params: CodexThreadForkParams,
): Promise<CodexThreadForkResponse> {
if (client.forkThread) {
return client.forkThread(params);
}
return parseCodexThreadForkResponse(await client.request("thread/fork", params));
}
async function rollbackCodexThread(
client: CodexRewindClient,
params: CodexThreadRollbackParams,
): Promise<CodexThreadRollbackResponse> {
if (client.rollbackThread) {
return client.rollbackThread(params);
}
return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params));
}
export async function revertCodexConversation(input: {
client: CodexRewindClient;
threadId: string | null;
messageId: string;
cwd?: string | null;
model?: string | null;
serviceTier?: string | null;
userMessageTurns: CodexUserMessageTurnIndex;
setThreadId: (threadId: string) => void | Promise<void>;
}): Promise<void> {
if (!input.threadId) {
throw new Error("Codex thread is not ready for rewind");
}
const targetTurnIndex = input.userMessageTurns.resolve(input.messageId);
if (targetTurnIndex === null) {
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
}
const currentUserTurnCount = input.userMessageTurns.count();
const numTurns = currentUserTurnCount - targetTurnIndex;
if (numTurns < 0) {
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
}
// Fork is non-destructive: the old thread file stays on disk and remains
// recoverable with `codex resume <old-uuid>` if the rewind target was wrong.
const forked = await forkCodexThread(input.client, {
threadId: input.threadId,
cwd: input.cwd ?? null,
model: input.model ?? null,
serviceTier: input.serviceTier ?? null,
excludeTurns: false,
persistExtendedHistory: true,
});
const forkedThreadId = forked.thread.id;
// Codex rollback is chat-only by design. File edits from rewound turns stay
// on disk; a future file primitive would be a separate capability.
const rolledBack = await rollbackCodexThread(input.client, {
threadId: forkedThreadId,
numTurns,
});
await input.setThreadId(rolledBack.thread.id);
}
@@ -14,8 +14,11 @@ type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
export interface FakeCodexAppServer {
readonly child: CodexAppServerChildProcess;
readonly recordedRollbacks: JsonObject[];
assertNoErrors(): void;
waitForTurnStart(): Promise<JsonObject>;
nextResponse(): Promise<string>;
completeTurn(params?: { threadId?: string }): void;
requestCommandApproval(params: {
itemId: string;
threadId: string;
@@ -46,6 +49,7 @@ export function createFakeCodexAppServer(
handlers: Record<string, FakeCodexAppServerHandler> = {},
): FakeCodexAppServer {
const child = createCodexAppServerChildProcess();
const recordedRollbacks: JsonObject[] = [];
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
initialize: () => ({}),
"collaborationMode/list": () => ({ data: [] }),
@@ -65,6 +69,38 @@ export function createFakeCodexAppServer(
"thread/loaded/list": () => ({ data: [] }),
"thread/resume": () => ({}),
"turn/start": () => ({}),
"thread/fork": (params) => ({
thread: {
id: "forked-thread",
sessionId: "forked-session",
forkedFromId: toJsonObject(params).threadId,
turns: [],
},
model: "gpt-5.4",
modelProvider: "openai",
serviceTier: null,
cwd: "/workspace/project",
runtimeWorkspaceRoots: [],
instructionSources: [],
approvalPolicy: "on-request",
approvalsReviewer: null,
sandbox: { type: "workspaceWrite", networkAccess: false },
activePermissionProfile: null,
reasoningEffort: null,
}),
"thread/rollback": (params) => {
const rollback = toJsonObject(params);
recordedRollbacks.push(rollback);
return {
thread: {
id: typeof rollback.threadId === "string" ? rollback.threadId : "forked-thread",
sessionId: "forked-session",
forkedFromId: "thread-1",
turns: [],
},
};
},
"thread/read": () => ({ thread: { turns: [] } }),
...handlers,
};
const messages: JsonObject[] = [];
@@ -161,6 +197,7 @@ export function createFakeCodexAppServer(
return {
child,
recordedRollbacks,
assertNoErrors() {
if (errors.length > 0) {
throw errors[0];
@@ -173,6 +210,19 @@ export function createFakeCodexAppServer(
);
return toJsonObject(message.params);
},
nextResponse() {
return new Promise<string>((resolve) => {
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
});
},
completeTurn(params = {}) {
child.stdout.write(
`${JSON.stringify({
method: "turn/completed",
params: { threadId: params.threadId ?? "thread-1", turn: { status: "completed" } },
})}\n`,
);
},
requestCommandApproval(params) {
const requestId = nextServerRequestId;
nextServerRequestId += 1;
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@ const CodexCommandValueSchema = z.union([z.string(), z.array(z.string())]);
const CodexToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
type CodexToolCallStatus = z.infer<typeof CodexToolCallStatusSchema>;
const CodexRolloutToolCallParamsSchema = z
const CodexToolCallEnvelopeParamsSchema = z
.object({
callId: z.string().optional().nullable(),
name: z.string().min(1),
@@ -432,7 +432,7 @@ function asEditTextFields(text: string | undefined): { unifiedDiff?: string; new
return { newString: text };
}
function findRolloutEditPatchText(input: Record<string, unknown>): string | undefined {
function findToolCallEditPatchText(input: Record<string, unknown>): string | undefined {
return (
(typeof input.patch === "string" && input.patch) ||
(typeof input.diff === "string" && input.diff) ||
@@ -443,7 +443,7 @@ function findRolloutEditPatchText(input: Record<string, unknown>): string | unde
);
}
function findRolloutEditInputPath(
function findToolCallEditInputPath(
input: Record<string, unknown>,
patchText: string,
): string | undefined {
@@ -459,14 +459,14 @@ function findRolloutEditInputPath(
);
}
function normalizeRolloutEditRecordInput(input: Record<string, unknown>): unknown {
const candidatePatchText = findRolloutEditPatchText(input);
function normalizeToolCallEditRecordInput(input: Record<string, unknown>): unknown {
const candidatePatchText = findToolCallEditPatchText(input);
if (!candidatePatchText) {
return input;
}
const textFields = asEditTextFields(candidatePatchText);
const rawPath = findRolloutEditInputPath(input, candidatePatchText);
const rawPath = findToolCallEditInputPath(input, candidatePatchText);
const {
patch: _patch,
@@ -490,7 +490,7 @@ function normalizeRolloutEditRecordInput(input: Record<string, unknown>): unknow
return normalized;
}
function normalizeRolloutEditInput(input: unknown): unknown {
function normalizeToolCallEditInput(input: unknown): unknown {
if (typeof input === "string") {
const textFields = asEditTextFields(input);
const path = extractPatchPrimaryFilePath(input);
@@ -503,7 +503,7 @@ function normalizeRolloutEditInput(input: unknown): unknown {
if (!isRecord(input)) {
return input;
}
return normalizeRolloutEditRecordInput(input);
return normalizeToolCallEditRecordInput(input);
}
function asEditFileOutputFields(text: string | undefined): { patch?: string; content?: string } {
@@ -965,7 +965,7 @@ export function mapCodexToolCallFromThreadItem(
return toToolCallFromNormalizedEnvelope(envelope);
}
export function mapCodexRolloutToolCall(params: {
export function mapCodexToolCallEnvelope(params: {
callId?: string | null;
name: string;
input?: unknown;
@@ -973,7 +973,7 @@ export function mapCodexRolloutToolCall(params: {
error?: unknown;
cwd?: string | null;
}): ToolCallTimelineItem | null {
const parsed = CodexRolloutToolCallParamsSchema.safeParse(params);
const parsed = CodexToolCallEnvelopeParamsSchema.safeParse(params);
if (!parsed.success) {
return null;
}
@@ -988,7 +988,7 @@ export function mapCodexRolloutToolCall(params: {
}
const normalizedInput =
normalizedName === "apply_patch" || normalizedName === "apply_diff"
? normalizeRolloutEditInput(parsed.data.input ?? null)
? normalizeToolCallEditInput(parsed.data.input ?? null)
: (parsed.data.input ?? null);
return toToolCallFromNormalizedEnvelope({
@@ -27,6 +27,9 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
@@ -42,6 +42,9 @@ const CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsRewindBoth: true,
};
const MODELS: AgentModelDefinition[] = [
@@ -449,12 +452,17 @@ export class MockLoadTestAgentSession implements AgentSession {
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private modeId: string | null;
private modelId: string | null;
private readonly rewindError: string | null;
constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) {
this.id = options.sessionId;
this.logger = options.logger;
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
this.rewindError =
typeof options.config.featureValues?.mockRewindError === "string"
? options.config.featureValues.mockRewindError
: null;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
@@ -495,6 +503,22 @@ export class MockLoadTestAgentSession implements AgentSession {
turnStarted: false,
};
this.activeTurn = turn;
const userMessageId = randomUUID();
setTimeout(() => {
if (this.activeTurn?.turnId !== turnId) {
return;
}
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "user_message",
text: promptToText(prompt),
messageId: userMessageId,
},
});
}, 0);
const largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
const stress = parseAgentStreamStressPrompt(prompt);
@@ -609,6 +633,21 @@ export class MockLoadTestAgentSession implements AgentSession {
this.listeners.clear();
}
async revertConversation(_input: { messageId: string }): Promise<void> {
this.failConfiguredRewind();
this.keepFirstUserMessageHistory();
}
async revertFiles(_input: { messageId: string }): Promise<void> {
this.failConfiguredRewind();
this.keepFirstUserMessageHistory();
}
async revertBoth(_input: { messageId: string }): Promise<void> {
this.failConfiguredRewind();
this.keepFirstUserMessageHistory();
}
async setModel(modelId: string | null): Promise<void> {
this.modelId = modelId ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
}
@@ -620,6 +659,12 @@ export class MockLoadTestAgentSession implements AgentSession {
turn.timer.unref?.();
}
private failConfiguredRewind(): void {
if (this.rewindError) {
throw new Error(this.rewindError);
}
}
private scheduleLargePayloadTurn(
turn: ActiveTurn,
largePayload: LargeAgentStreamPayloadRequest,
@@ -941,7 +986,7 @@ export class MockLoadTestAgentSession implements AgentSession {
}
private emit(event: AgentStreamEvent): void {
this.history.push(event);
this.remember(event);
for (const listener of this.listeners) {
try {
listener(event);
@@ -951,6 +996,22 @@ export class MockLoadTestAgentSession implements AgentSession {
}
}
private remember(event: AgentStreamEvent): void {
this.history.push(event);
}
private keepFirstUserMessageHistory(): void {
const nextHistory: AgentStreamEvent[] = [];
for (const event of this.history) {
if (event.type === "timeline" && event.item.type === "user_message") {
nextHistory.push(event);
break;
}
}
this.history.length = 0;
this.history.push(...nextHistory);
}
private clearTurnTimer(turn: ActiveTurn): void {
if (!turn.timer) {
return;
@@ -937,6 +937,10 @@ describe("OpenCode adapter startTurn error handling", () => {
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
const fakeClient = {
session: {
get: vi.fn().mockResolvedValue({
data: { revert: undefined },
error: undefined,
}),
messages: vi.fn().mockResolvedValue({
data: [
{
@@ -1029,6 +1033,10 @@ describe("OpenCode adapter startTurn error handling", () => {
test("streamHistory omits replay timestamps when OpenCode omits times", async () => {
const fakeClient = {
session: {
get: vi.fn().mockResolvedValue({
data: { revert: undefined },
error: undefined,
}),
messages: vi.fn().mockResolvedValue({
data: [
{
@@ -67,6 +67,7 @@ import {
type OpenCodeServerAcquisition,
} from "./opencode/runtime.js";
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -75,6 +76,9 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: true,
};
const OPENCODE_BUILD_MODE_ID = "build";
@@ -700,6 +704,24 @@ function buildOpenCodePromptParts(
return output;
}
function buildOpenCodeUserTimelineText(prompt: AgentPromptInput): string {
if (typeof prompt === "string") {
return prompt;
}
return prompt
.map((part) => {
if (part.type === "text") {
return part.text;
}
if (part.type === "image") {
return "[Image]";
}
return renderPromptAttachmentAsText(part);
})
.filter((text) => text.trim().length > 0)
.join("\n");
}
async function collectOpenCodePersistedAgentsFromSdk(
client: Pick<OpencodeClient, "experimental" | "session">,
options?: ListPersistedAgentsOptions,
@@ -845,7 +867,7 @@ async function readOpenCodeSessionMessagesFromSdk(
return [];
}
return response.data;
return filterOpenCodeRevertedMessages(response.data, session.revert);
}
function buildOpenCodeSessionTimeline(
@@ -856,6 +878,20 @@ function buildOpenCodeSessionTimeline(
);
}
function filterOpenCodeRevertedMessages(
messages: ReadonlyArray<OpenCodeSessionMessage>,
revert: OpenCodePersistedSession["revert"] | null | undefined,
): OpenCodeSessionMessage[] {
if (!revert?.messageID || revert.partID) {
return [...messages];
}
const revertIndex = messages.findIndex((message) => message.info.id === revert.messageID);
if (revertIndex < 0) {
return [...messages];
}
return messages.slice(0, revertIndex);
}
function resolveOpenCodePersistedSessionModeId(
session: OpenCodePersistedSession,
messages: ReadonlyArray<OpenCodeSessionMessage>,
@@ -1352,6 +1388,8 @@ export interface OpenCodeEventTranslationState {
sessionId: string;
cwd?: string;
messageRoles: Map<string, OpenCodeMessageRole>;
pendingUserMessageText?: string | null;
emittedUserMessageIds?: Set<string>;
accumulatedUsage: AgentUsage;
sessionTotalCostUsd?: number;
streamedPartKeys: Set<string>;
@@ -1969,6 +2007,10 @@ function appendOpenCodeMessageUpdated(
return;
}
state.messageRoles.set(info.id, info.role);
if (info.role === "user") {
appendOpenCodeUserMessageUpdated(info, state, events);
return;
}
if (info.role !== "assistant") {
return;
}
@@ -1994,6 +2036,23 @@ function appendOpenCodeMessageUpdated(
});
}
function appendOpenCodeUserMessageUpdated(
info: Extract<OpenCodeMessage, { role: "user" }>,
state: OpenCodeEventTranslationState,
events: AgentStreamEvent[],
): void {
const text = state.pendingUserMessageText;
if (!text || text.trim().length === 0 || state.emittedUserMessageIds?.has(info.id)) {
return;
}
state.emittedUserMessageIds?.add(info.id);
events.push({
type: "timeline",
provider: "opencode",
item: { type: "user_message", text, messageId: info.id },
});
}
function appendOpenCodeMessagePartUpdated(
event: Extract<OpenCodeEvent, { type: "message.part.updated" }>,
state: OpenCodeEventTranslationState,
@@ -2322,6 +2381,8 @@ class OpenCodeAgentSession implements AgentSession {
private mcpSetupPromise: Promise<void> | null = null;
/** Tracks the role of each message by ID to distinguish user from assistant messages */
private messageRoles = new Map<string, OpenCodeMessageRole>();
private pendingUserMessageText: string | null = null;
private emittedUserMessageIds = new Set<string>();
/** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */
private streamedPartKeys = new Set<string>();
/** Tracks assistant messages already emitted from structured payloads. */
@@ -2432,6 +2493,15 @@ class OpenCodeAgentSession implements AgentSession {
}
}
async revertBoth(input: { messageId: string }): Promise<void> {
await revertOpenCodeConversationAndFiles({
client: this.client,
sessionId: this.sessionId,
cwd: this.config.cwd,
messageId: input.messageId,
});
}
private beginSessionAbort(turnId: string | null, reason: string): Promise<void> {
const abortPromise = this.client.session
.abort({
@@ -2492,6 +2562,7 @@ class OpenCodeAgentSession implements AgentSession {
this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
const parts = buildOpenCodePromptParts(prompt);
this.pendingUserMessageText = buildOpenCodeUserTimelineText(prompt);
const model = this.parseModel(this.config.model);
const thinkingOptionId = this.config.thinkingOptionId;
const effectiveVariant = thinkingOptionId ?? undefined;
@@ -2856,6 +2927,7 @@ class OpenCodeAgentSession implements AgentSession {
} else {
this.runningToolCalls.clear();
}
this.pendingUserMessageText = null;
this.activeForegroundTurnId = null;
this.abortController = null;
this.notifySubscribers(event, turnId);
@@ -2934,6 +3006,10 @@ class OpenCodeAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
const sessionResponse = await this.client.session.get({
sessionID: this.sessionId,
directory: this.config.cwd,
});
const response = await this.client.session.messages({
sessionID: this.sessionId,
directory: this.config.cwd,
@@ -2943,7 +3019,11 @@ class OpenCodeAgentSession implements AgentSession {
return;
}
for (const message of response.data) {
const messages = filterOpenCodeRevertedMessages(
response.data,
sessionResponse.error ? null : sessionResponse.data?.revert,
);
for (const message of messages) {
for (const event of buildOpenCodeReplayTimelineEvents(message)) {
yield event;
}
@@ -3215,6 +3295,8 @@ class OpenCodeAgentSession implements AgentSession {
sessionId: this.sessionId,
cwd: this.config.cwd,
messageRoles: this.messageRoles,
pendingUserMessageText: this.pendingUserMessageText,
emittedUserMessageIds: this.emittedUserMessageIds,
accumulatedUsage: this.accumulatedUsage,
sessionTotalCostUsd: this.sessionTotalCostUsd,
streamedPartKeys: this.streamedPartKeys,
@@ -0,0 +1,69 @@
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "../opencode-agent.js";
import { revertOpenCodeConversationAndFiles, type OpenCodeRewindClient } from "./rewind.js";
function rewindCapabilities(capabilities: OpenCodeAgentClient["capabilities"]) {
return {
supportsRewindConversation: capabilities.supportsRewindConversation,
supportsRewindFiles: capabilities.supportsRewindFiles,
supportsRewindBoth: capabilities.supportsRewindBoth,
};
}
class FakeOpencodeClient implements OpenCodeRewindClient {
readonly recordedReverts: Array<{ sessionID: string; directory: string; messageID: string }> = [];
revertResponse: Awaited<ReturnType<OpenCodeRewindClient["session"]["revert"]>> = {};
readonly session = {
revert: async (input: { sessionID: string; directory: string; messageID: string }) => {
this.recordedReverts.push(input);
return this.revertResponse;
},
};
}
describe("OpenCode rewind", () => {
test("rewinds conversation and files to the OpenCode user message", async () => {
const opencode = new FakeOpencodeClient();
await revertOpenCodeConversationAndFiles({
client: opencode,
sessionId: "session-1",
cwd: "/workspace/project",
messageId: "user-message-1",
});
expect(opencode.recordedReverts).toEqual([
{ sessionID: "session-1", directory: "/workspace/project", messageID: "user-message-1" },
]);
});
test("surfaces OpenCode revert errors", async () => {
const opencode = new FakeOpencodeClient();
opencode.revertResponse = { error: { name: "NotFoundError", message: "missing message" } };
await expect(
revertOpenCodeConversationAndFiles({
client: opencode,
sessionId: "session-1",
cwd: "/workspace/project",
messageId: "missing-message",
}),
).rejects.toThrow("missing message");
expect(opencode.recordedReverts).toEqual([
{ sessionID: "session-1", directory: "/workspace/project", messageID: "missing-message" },
]);
});
test("declares only combined rewind capability", () => {
const client = new OpenCodeAgentClient(createTestLogger());
expect(rewindCapabilities(client.capabilities)).toEqual({
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: true,
});
});
});
@@ -0,0 +1,29 @@
import { toDiagnosticErrorMessage } from "../diagnostic-utils.js";
export interface OpenCodeRewindClient {
session: {
revert(input: {
sessionID: string;
directory: string;
messageID: string;
}): Promise<{ error?: unknown }>;
};
}
export async function revertOpenCodeConversationAndFiles(input: {
client: OpenCodeRewindClient;
sessionId: string;
cwd: string;
messageId: string;
}): Promise<void> {
// OpenCode keeps unrevert available only until a later prompt triggers its cleanup,
// which permanently drops reverted messages. Paseo v1 only exposes revert.
const response = await input.client.session.revert({
sessionID: input.sessionId,
directory: input.cwd,
messageID: input.messageId,
});
if (response.error) {
throw new Error(toDiagnosticErrorMessage(response.error));
}
}
@@ -1,6 +1,4 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
import pino from "pino";
import { describe, expect, test } from "vitest";
@@ -15,12 +13,12 @@ function createClient(pi = new FakePi()): PiRpcAgentClient {
});
}
function createClientWithPiAgentDir(agentDir: string): PiRpcAgentClient {
return new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: new FakePi(),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
});
function rewindCapabilities(capabilities: PiRpcAgentSession["capabilities"]) {
return {
supportsRewindConversation: capabilities.supportsRewindConversation,
supportsRewindFiles: capabilities.supportsRewindFiles,
supportsRewindBoth: capabilities.supportsRewindBoth,
};
}
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
@@ -31,6 +29,17 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
};
}
function readUtf8File(pathname: string): string {
const fd = openSync(pathname, "r");
try {
const buffer = Buffer.alloc(fstatSync(fd).size);
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
closeSync(fd);
}
}
async function createSession(pi = new FakePi()): Promise<{
pi: FakePi;
session: PiRpcAgentSession;
@@ -116,6 +125,13 @@ class SessionEvents {
);
}
nextTimelineEvent(): Promise<Extract<AgentStreamEvent, { type: "timeline" }>> {
return this.nextEvent(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline",
);
}
private nextEvent<T extends AgentStreamEvent>(
predicate: (event: AgentStreamEvent) => event is T,
): Promise<T> {
@@ -306,6 +322,50 @@ describe("PiRpcAgentSession", () => {
]);
});
test("emits live user messages with captured Pi tree entry ids", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.capturedUserEntries = [{ id: "entry-user-1", parentId: null, text: "hello" }];
await session.startTurn("hello");
fakeSession.emit({
type: "message_end",
message: { role: "user", content: "hello" },
});
await events.nextTimelineEvent();
expect(events.timelineItems()).toEqual([
{ type: "user_message", text: "hello", messageId: "entry-user-1" },
]);
});
test("adds Pi assistant context to generic provider finish errors", async () => {
const { pi, session, events } = await createSession();
await session.startTurn("write qa");
pi.latestSession().finishTurn({
role: "assistant",
provider: "openrouter",
model: "google/gemini-2.5-flash-lite",
responseId: "gen-test",
stopReason: "error",
errorMessage: "Provider finish_reason: error",
content: [
{
type: "thinking",
thinking: "I will use the write tool for qa.txt.",
},
],
});
await expect(events.nextTurnFailure()).resolves.toMatchObject({
error: expect.stringContaining(
'Provider finish_reason: error (stopReason=error, model=openrouter/google/gemini-2.5-flash-lite, responseId=gen-test, partial="I will use the write tool for qa.txt.")',
),
});
});
test("resumes by launching Pi with the persisted session file and cwd metadata", async () => {
const pi = new FakePi();
const client = createClient(pi);
@@ -314,7 +374,7 @@ describe("PiRpcAgentSession", () => {
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session.jsonl",
nativeHandle: "/tmp/native-pi-session",
metadata: {
cwd: "/workspace/project",
model: "openrouter/model-a",
@@ -324,22 +384,25 @@ describe("PiRpcAgentSession", () => {
{},
);
expect(pi.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/native-pi-session.jsonl",
argv: [
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session.jsonl",
],
}),
expect(pi.recordedLaunches).toHaveLength(1);
const actualLaunch = pi.recordedLaunches[0]!;
expect(actualLaunch).toMatchObject({
cwd: "/workspace/project",
session: "/tmp/native-pi-session",
});
expect(actualLaunch.extensionPaths).toHaveLength(1);
expect(actualLaunch.argv).toEqual([
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session",
"--extension",
actualLaunch.extensionPaths[0],
]);
});
@@ -354,21 +417,23 @@ describe("PiRpcAgentSession", () => {
}),
);
expect(pi.recordedLaunches[0]).toEqual(
expect.objectContaining({
cwd: "/tmp/paseo-pi-rpc-test",
systemPrompt: "Agent prompt\n\nDaemon prompt",
argv: [
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
],
}),
);
const actualLaunch = pi.recordedLaunches[0]!;
expect(actualLaunch).toMatchObject({
cwd: "/tmp/paseo-pi-rpc-test",
systemPrompt: "Agent prompt\n\nDaemon prompt",
});
expect(actualLaunch.extensionPaths).toHaveLength(1);
expect(actualLaunch.argv).toEqual([
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
"--extension",
actualLaunch.extensionPaths[0],
]);
});
test("resumes Pi sessions with daemon system prompts appended", async () => {
@@ -379,7 +444,7 @@ describe("PiRpcAgentSession", () => {
{
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: "/tmp/native-pi-session.jsonl",
nativeHandle: "/tmp/native-pi-session",
metadata: {
cwd: "/workspace/project",
model: "openrouter/model-a",
@@ -392,25 +457,28 @@ describe("PiRpcAgentSession", () => {
},
);
expect(pi.recordedLaunches).toEqual([
expect.objectContaining({
cwd: "/workspace/project",
session: "/tmp/native-pi-session.jsonl",
systemPrompt: "Agent prompt\n\nDaemon prompt",
argv: [
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session.jsonl",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
],
}),
expect(pi.recordedLaunches).toHaveLength(1);
const actualLaunch = pi.recordedLaunches[0]!;
expect(actualLaunch).toMatchObject({
cwd: "/workspace/project",
session: "/tmp/native-pi-session",
systemPrompt: "Agent prompt\n\nDaemon prompt",
});
expect(actualLaunch.extensionPaths).toHaveLength(1);
expect(actualLaunch.argv).toEqual([
"pi",
"--mode",
"rpc",
"--model",
"openrouter/model-a",
"--thinking",
"high",
"--session",
"/tmp/native-pi-session",
"--append-system-prompt",
"Agent prompt\n\nDaemon prompt",
"--extension",
actualLaunch.extensionPaths[0],
]);
});
@@ -478,6 +546,27 @@ describe("PiRpcAgentClient", () => {
]);
});
test("rewinds conversation through the Pi tree navigation bridge", async () => {
const { pi, session, events } = await createSession();
pi.latestSession().capturedUserEntries = [
{ id: "entry-1", parentId: null, text: "first prompt" },
{ id: "entry-3", parentId: "entry-2", text: "second prompt" },
];
await session.startTurn("first prompt");
pi.latestSession().finishTurn({ role: "assistant", content: [] });
await events.nextTurnCompletion();
await session.revertConversation?.({ messageId: "entry-1" });
expect(rewindCapabilities(session.capabilities)).toEqual({
supportsRewindConversation: true,
supportsRewindFiles: false,
supportsRewindBoth: false,
});
expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]);
});
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
const pi = new FakePi();
pi.queueCommands([
@@ -512,7 +601,8 @@ describe("PiRpcAgentClient", () => {
cwd: "/tmp/paseo-pi-rpc-test",
argv: ["pi", "--mode", "rpc"],
});
const actualLaunch = pi.recordedLaunches[1];
const actualLaunch = pi.recordedLaunches[1]!;
expect(actualLaunch.extensionPaths).toHaveLength(1);
expect(actualLaunch.argv).toEqual([
"pi",
"--mode",
@@ -521,12 +611,14 @@ describe("PiRpcAgentClient", () => {
"medium",
"--mcp-config",
actualLaunch.mcpConfigPath,
"--extension",
actualLaunch.extensionPaths[0],
]);
expect(session.capabilities.supportsMcpServers).toBe(true);
const configPath = actualLaunch.mcpConfigPath;
expect(configPath).toEqual(expect.any(String));
const injectedConfig = JSON.parse(readFileSync(configPath!, "utf8")) as {
const injectedConfig = JSON.parse(readUtf8File(configPath!)) as {
mcpServers: Record<string, unknown>;
};
expect(injectedConfig).toEqual({
@@ -565,54 +657,19 @@ describe("PiRpcAgentClient", () => {
);
expect(pi.recordedLaunches).toHaveLength(2);
expect(pi.recordedLaunches[1]?.argv).toEqual(["pi", "--mode", "rpc", "--thinking", "medium"]);
expect(pi.recordedLaunches[1]?.mcpConfigPath).toBeUndefined();
expect(session.capabilities.supportsMcpServers).toBe(false);
});
test("lists persisted Pi sessions from the configured Pi agent directory", async () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-client-"));
const cwd = path.join(root, "workspace");
const agentDir = path.join(root, "agent");
const sessionsDir = path.join(agentDir, "sessions", "--workspace--");
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260101_session.jsonl");
writeFileSync(
sessionFile,
[
JSON.stringify({
type: "session",
version: 3,
id: "pi-session",
timestamp: "2026-01-01T00:00:00.000Z",
cwd,
}),
JSON.stringify({
type: "message",
id: "entry-1",
parentId: null,
timestamp: "2026-01-01T00:00:01.000Z",
message: { role: "user", content: "remember this" },
}),
].join("\n") + "\n",
"utf8",
);
const client = createClientWithPiAgentDir(agentDir);
await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([
{
provider: "pi",
sessionId: "pi-session",
cwd,
persistence: {
provider: "pi",
sessionId: "pi-session",
nativeHandle: sessionFile,
metadata: { provider: "pi", cwd },
},
timeline: [{ type: "user_message", text: "remember this" }],
},
const actualLaunch = pi.recordedLaunches[1]!;
expect(actualLaunch.extensionPaths).toHaveLength(1);
expect(actualLaunch.argv).toEqual([
"pi",
"--mode",
"rpc",
"--thinking",
"medium",
"--extension",
actualLaunch.extensionPaths[0],
]);
expect(actualLaunch.mcpConfigPath).toBeUndefined();
expect(session.capabilities.supportsMcpServers).toBe(false);
});
});
@@ -41,9 +41,13 @@ import {
resolveBinaryVersion,
toDiagnosticErrorMessage,
} from "../diagnostic-utils.js";
import { streamPiHistory } from "./history-mapper.js";
import {
getUserMessageText,
streamPiHistory,
type PiCapturedUserMessageEntry,
} from "./history-mapper.js";
import { PiCliRuntime } from "./cli-runtime.js";
import { listPiPersistedAgents } from "./session-descriptor.js";
import { revertPiConversation } from "./rewind.js";
import type { PiRuntime, PiRuntimeSession } from "./runtime.js";
import type {
PiAgentSessionEvent,
@@ -68,6 +72,11 @@ import {
const PI_PROVIDER = "pi";
const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium";
const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi";
const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree";
const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries";
const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE";
const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT";
const PASEO_PI_EXTENSION_RESULT_TIMEOUT_MS = 10_000;
const PI_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
@@ -76,6 +85,9 @@ const PI_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: true,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
const PI_THINKING_OPTIONS: ReadonlyArray<{
@@ -149,6 +161,26 @@ interface PiMcpConfigFile {
cleanup: () => void;
}
interface PiTempFile {
path: string;
cleanup: () => void;
}
interface PiCapturedEntry extends PiCapturedUserMessageEntry {
parentId: string | null;
}
interface PendingPiUserMessage {
text: string;
turnId: string | undefined;
}
interface PendingExtensionResult {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
function normalizePiModelLabel(label: string): string {
return label.trim().replace(/[_\s]+/g, " ");
}
@@ -359,6 +391,109 @@ function createPiMcpConfigFile(servers: Record<string, McpServerConfig>): PiMcpC
};
}
function createPiPaseoExtensionFile(): PiTempFile {
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-"));
const filePath = join(dir, "paseo-integration.mjs");
writeFileSync(
filePath,
`
function decodePayload(encoded) {
return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
}
function readTextContent(content) {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.filter((part) => part && part.type === "text" && typeof part.text === "string")
.map((part) => part.text)
.join("\\n\\n");
}
function getCapturedUserEntries(ctx) {
return ctx.sessionManager
.getEntries()
.filter((entry) => entry.type === "message" && entry.message?.role === "user")
.map((entry) => ({
id: entry.id,
parentId: entry.parentId ?? null,
text: readTextContent(entry.message.content),
}));
}
function emitEntryCapture(ctx, reason, requestId) {
ctx.ui.notify(
"${PASEO_PI_ENTRY_CAPTURE_MARKER} " +
JSON.stringify({ reason, requestId, entries: getCapturedUserEntries(ctx) }),
"info",
);
}
function emitCommandResult(ctx, requestId, result) {
ctx.ui.notify(
"${PASEO_PI_COMMAND_RESULT_MARKER} " + JSON.stringify({ requestId, ...result }),
result.ok ? "info" : "error",
);
}
export default function paseoIntegration(pi) {
pi.on("session_start", async (_event, ctx) => {
emitEntryCapture(ctx, "session_start");
});
pi.on("turn_end", async (_event, ctx) => {
emitEntryCapture(ctx, "turn_end");
});
pi.registerCommand("${PASEO_PI_CAPTURE_EXTENSION_COMMAND}", {
description: "Internal Paseo entry capture bridge",
handler: async (args, ctx) => {
const payload = decodePayload(args.trim());
emitEntryCapture(ctx, "command", payload.requestId);
},
});
pi.registerCommand("${PASEO_PI_TREE_EXTENSION_COMMAND}", {
description: "Internal Paseo tree navigation bridge",
handler: async (args, ctx) => {
const payload = decodePayload(args.trim());
try {
const result = await ctx.navigateTree(payload.targetId, { summarize: false });
emitEntryCapture(ctx, "tree_navigation");
emitCommandResult(ctx, payload.requestId, { ok: true, result });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
emitCommandResult(ctx, payload.requestId, { ok: false, error: message });
throw error;
}
},
});
}
`.trimStart(),
"utf8",
);
return {
path: filePath,
cleanup: () => rmSync(dir, { recursive: true, force: true }),
};
}
function combineCleanup(cleanups: Array<(() => void) | undefined>): (() => void) | undefined {
const activeCleanups = cleanups.filter((cleanup): cleanup is () => void => Boolean(cleanup));
if (activeCleanups.length === 0) {
return undefined;
}
return () => {
for (const cleanup of activeCleanups) {
cleanup();
}
};
}
function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean {
if (command.source !== "extension" || !/^mcp(?::\d+)?$/.test(command.name)) {
return false;
@@ -396,11 +531,43 @@ function modelToId(model: PiModel | null | undefined): string | null {
return model?.provider && model.id ? `${model.provider}/${model.id}` : null;
}
function piAssistantText(message: Extract<PiAgentMessage, { role: "assistant" }>): string | null {
const text = message.content
.flatMap((part) => {
if (part.type === "text") {
return [part.text];
}
if (part.type === "thinking") {
return [part.thinking];
}
return [];
})
.join("\n\n")
.trim();
return text.length > 0 ? text : null;
}
function formatPiErrorMessage(message: Extract<PiAgentMessage, { role: "assistant" }>): string {
const headline = message.errorMessage?.trim() || "Pi turn failed";
const details = [
message.stopReason ? `stopReason=${message.stopReason}` : null,
message.provider && message.model ? `model=${message.provider}/${message.model}` : null,
message.responseModel ? `responseModel=${message.responseModel}` : null,
message.responseId ? `responseId=${message.responseId}` : null,
].filter((detail): detail is string => detail !== null);
const partialText = piAssistantText(message);
if (partialText) {
details.push(`partial=${JSON.stringify(partialText.slice(0, 500))}`);
}
return details.length > 0 ? `${headline} (${details.join(", ")})` : headline;
}
function latestPiErrorMessage(messages: PiAgentMessage[]): string | null {
const latestAssistant = messages.findLast((message) => message.role === "assistant");
return latestAssistant && "errorMessage" in latestAssistant
? (latestAssistant.errorMessage ?? null)
: null;
if (!latestAssistant || !latestAssistant.errorMessage?.trim()) {
return null;
}
return formatPiErrorMessage(latestAssistant);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -411,6 +578,46 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function parseExtensionMarkerPayload(
message: string,
marker: string,
): Record<string, unknown> | null {
const prefix = `${marker} `;
if (!message.startsWith(prefix)) {
return null;
}
try {
const parsed = JSON.parse(message.slice(prefix.length)) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function parseCapturedEntries(value: unknown): PiCapturedEntry[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((entry): PiCapturedEntry[] => {
if (!isRecord(entry)) {
return [];
}
const id = optionalString(entry.id)?.trim();
const text = optionalString(entry.text);
if (!id || text === undefined) {
return [];
}
const parentId = entry.parentId === null ? null : optionalString(entry.parentId)?.trim();
return [
{
id,
parentId: parentId || null,
text,
},
];
});
}
function mapExtensionUiRequestToPermission(
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
): AgentPermissionRequest | null {
@@ -533,6 +740,12 @@ export class PiRpcAgentSession implements AgentSession {
private readonly pendingExtensionUiRequests = new Map<string, AgentPermissionRequest>();
private activeTurnId: string | null = null;
private lastKnownThinkingOptionId: string | null;
currentLeafOverrideId: string | null | undefined;
private readonly capturedUserEntries: PiCapturedEntry[] = [];
private readonly capturedUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly seenUserEntryIds = new Set<string>();
private readonly pendingUserMessages: PendingPiUserMessage[] = [];
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
private state: PiSessionState;
private closed = false;
@@ -612,7 +825,12 @@ export class PiRpcAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield* streamPiHistory(PI_PROVIDER, await this.runtimeSession.getMessages());
await this.requestEntryCapture("history");
yield* streamPiHistory(
PI_PROVIDER,
await this.runtimeSession.getMessages(),
this.capturedUserEntries,
);
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -683,6 +901,35 @@ export class PiRpcAgentSession implements AgentSession {
await this.runtimeSession.abort();
}
async revertConversation(input: { messageId: string }): Promise<void> {
if (this.activeTurnId) {
throw new Error("Cannot rewind the Pi conversation while a Pi turn is active");
}
await this.refreshState().catch(() => undefined);
await this.requestEntryCapture("rewind");
const targetEntry = this.capturedUserEntriesById.get(input.messageId);
if (!targetEntry) {
throw new Error(`Pi rewind target ${input.messageId} was not found in captured tree entries`);
}
await revertPiConversation({
messageId: input.messageId,
navigator: {
navigateTree: (treeEntryId) => this.runPiTreeExtensionCommand(treeEntryId),
},
});
// Pi keeps all tree nodes, so selecting the previous leaf later reverses this rewind.
this.currentLeafOverrideId = targetEntry.parentId;
this.activeToolCalls.clear();
}
private async runPiTreeExtensionCommand(targetId: string): Promise<unknown> {
const requestId = randomUUID();
const resultPromise = this.waitForExtensionResult(requestId);
const payload = Buffer.from(JSON.stringify({ targetId, requestId })).toString("base64url");
await this.runtimeSession.prompt(`/${PASEO_PI_TREE_EXTENSION_COMMAND} ${payload}`);
return await resultPromise;
}
async close(): Promise<void> {
if (this.closed) {
return;
@@ -691,6 +938,7 @@ export class PiRpcAgentSession implements AgentSession {
try {
await this.runtimeSession.close();
} finally {
this.rejectAllExtensionResults(new Error("Pi session closed"));
this.cleanup?.();
}
}
@@ -742,9 +990,128 @@ export class PiRpcAgentSession implements AgentSession {
return this.activeTurnId ?? undefined;
}
private async requestEntryCapture(reason: string): Promise<void> {
const requestId = randomUUID();
const resultPromise = this.waitForExtensionResult(requestId);
const payload = Buffer.from(JSON.stringify({ requestId, reason })).toString("base64url");
await this.runtimeSession.prompt(`/${PASEO_PI_CAPTURE_EXTENSION_COMMAND} ${payload}`);
await resultPromise;
}
private waitForExtensionResult(requestId: string): Promise<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingExtensionResults.delete(requestId);
reject(new Error(`Pi extension result timed out for request ${requestId}`));
}, PASEO_PI_EXTENSION_RESULT_TIMEOUT_MS);
this.pendingExtensionResults.set(requestId, { resolve, reject, timer });
});
}
private resolveExtensionResult(requestId: string, result: unknown): void {
const pending = this.pendingExtensionResults.get(requestId);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pendingExtensionResults.delete(requestId);
pending.resolve(result);
}
private rejectExtensionResult(requestId: string, error: Error): void {
const pending = this.pendingExtensionResults.get(requestId);
if (!pending) {
return;
}
clearTimeout(pending.timer);
this.pendingExtensionResults.delete(requestId);
pending.reject(error);
}
private rejectAllExtensionResults(error: Error): void {
for (const requestId of this.pendingExtensionResults.keys()) {
this.rejectExtensionResult(requestId, error);
}
}
private recordCapturedUserEntries(entries: PiCapturedEntry[]): void {
const previouslySeenEntryIds = new Set(this.seenUserEntryIds);
this.capturedUserEntries.splice(0, this.capturedUserEntries.length, ...entries);
this.capturedUserEntriesById.clear();
for (const entry of entries) {
this.capturedUserEntriesById.set(entry.id, entry);
}
this.flushPendingUserMessages(previouslySeenEntryIds);
for (const entry of entries) {
this.seenUserEntryIds.add(entry.id);
}
}
private flushPendingUserMessages(previouslySeenEntryIds: Set<string>): void {
for (let index = 0; index < this.pendingUserMessages.length; index += 1) {
const pending = this.pendingUserMessages[index]!;
const entry = this.capturedUserEntries.find(
(candidate) => !previouslySeenEntryIds.has(candidate.id),
);
if (!entry) {
continue;
}
previouslySeenEntryIds.add(entry.id);
this.pendingUserMessages.splice(index, 1);
index -= 1;
this.emit({
type: "timeline",
provider: PI_PROVIDER,
turnId: pending.turnId,
item: {
type: "user_message",
text: pending.text,
messageId: entry.id,
},
});
}
}
private handleEntryCaptureMarker(message: string): boolean {
const payload = parseExtensionMarkerPayload(message, PASEO_PI_ENTRY_CAPTURE_MARKER);
if (!payload) {
return false;
}
const entries = parseCapturedEntries(payload.entries);
this.recordCapturedUserEntries(entries);
if (typeof payload.requestId === "string") {
this.resolveExtensionResult(payload.requestId, entries);
}
return true;
}
private handleCommandResultMarker(message: string): boolean {
const payload = parseExtensionMarkerPayload(message, PASEO_PI_COMMAND_RESULT_MARKER);
if (!payload) {
return false;
}
if (typeof payload.requestId !== "string") {
return true;
}
if (payload.ok === true) {
this.resolveExtensionResult(payload.requestId, payload.result);
return true;
}
const error = typeof payload.error === "string" ? payload.error : "Pi extension command failed";
this.rejectExtensionResult(payload.requestId, new Error(error));
return true;
}
private handleExtensionUiRequest(
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
): void {
const message = optionalString(event.message);
if (event.method === "notify" && message) {
if (this.handleEntryCaptureMarker(message) || this.handleCommandResultMarker(message)) {
return;
}
}
const request = mapExtensionUiRequestToPermission(event);
if (!request) {
return;
@@ -772,6 +1139,7 @@ export class PiRpcAgentSession implements AgentSession {
}
private handleProcessExit(error: string): void {
this.rejectAllExtensionResults(new Error(error));
if (!this.activeTurnId) {
return;
}
@@ -803,6 +1171,11 @@ export class PiRpcAgentSession implements AgentSession {
turnId,
});
return;
case "message_start":
return;
case "message_end":
this.handleMessageEnd(event, turnId);
return;
case "message_update":
this.handleMessageUpdate(event, turnId);
return;
@@ -896,6 +1269,29 @@ export class PiRpcAgentSession implements AgentSession {
}
}
private handleMessageEnd(
event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
turnId: string | undefined,
): void {
if (event.message.role !== "user") {
return;
}
const text = getUserMessageText(event.message.content);
if (!text) {
return;
}
this.pendingUserMessages.push({ text, turnId });
void this.requestEntryCapture("message_end").catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
this.emit({
type: "turn_failed",
provider: PI_PROVIDER,
turnId,
error: message,
});
});
}
private emitToolCallEvent(
toolCallId: string,
toolCall: PiTrackedToolCall,
@@ -981,6 +1377,7 @@ export class PiRpcAgentClient implements AgentClient {
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
const paseoExtension = createPiPaseoExtensionFile();
let runtimeSession: PiRuntimeSession;
try {
runtimeSession = await this.runtime.startSession({
@@ -994,9 +1391,11 @@ export class PiRpcAgentClient implements AgentClient {
),
env: launchContext?.env,
mcpConfigPath: mcpConfig?.path,
extensionPaths: [paseoExtension.path],
});
} catch (error) {
mcpConfig?.cleanup();
paseoExtension.cleanup();
throw error;
}
try {
@@ -1005,11 +1404,12 @@ export class PiRpcAgentClient implements AgentClient {
config,
initialState: await runtimeSession.getState(),
capabilities: withPiMcpCapability(mcpConfig !== null),
cleanup: mcpConfig?.cleanup,
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
});
} catch (error) {
await runtimeSession.close().catch(() => undefined);
mcpConfig?.cleanup();
paseoExtension.cleanup();
throw error;
}
}
@@ -1028,6 +1428,7 @@ export class PiRpcAgentClient implements AgentClient {
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers);
const paseoExtension = createPiPaseoExtensionFile();
let runtimeSession: PiRuntimeSession;
try {
runtimeSession = await this.runtime.startSession({
@@ -1040,9 +1441,11 @@ export class PiRpcAgentClient implements AgentClient {
resumeConfig.config.daemonAppendSystemPrompt,
),
mcpConfigPath: mcpConfig?.path,
extensionPaths: [paseoExtension.path],
});
} catch (error) {
mcpConfig?.cleanup();
paseoExtension.cleanup();
throw error;
}
try {
@@ -1051,11 +1454,12 @@ export class PiRpcAgentClient implements AgentClient {
config: resumeConfig.config,
initialState: await runtimeSession.getState(),
capabilities: withPiMcpCapability(mcpConfig !== null),
cleanup: mcpConfig?.cleanup,
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
});
} catch (error) {
await runtimeSession.close().catch(() => undefined);
mcpConfig?.cleanup();
paseoExtension.cleanup();
throw error;
}
}
@@ -1074,12 +1478,9 @@ export class PiRpcAgentClient implements AgentClient {
}
async listPersistedAgents(
options?: ListPersistedAgentsOptions,
_options?: ListPersistedAgentsOptions,
): Promise<PersistedAgentDescriptor[]> {
return await listPiPersistedAgents({
...options,
runtimeSettings: this.runtimeSettings,
});
return [];
}
async isAvailable(): Promise<boolean> {
@@ -2,11 +2,14 @@ import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import type { PiAgentMessage } from "./rpc-types.js";
import { streamPiHistory } from "./history-mapper.js";
import { streamPiHistory, type PiCapturedUserMessageEntry } from "./history-mapper.js";
async function collectHistory(messages: PiAgentMessage[]): Promise<AgentStreamEvent[]> {
async function collectHistory(
messages: PiAgentMessage[],
userEntries: PiCapturedUserMessageEntry[] = [],
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of streamPiHistory("pi", messages)) {
for await (const event of streamPiHistory("pi", messages, userEntries)) {
events.push(event);
}
return events;
@@ -46,7 +49,6 @@ describe("Pi history mapper", () => {
item: {
type: "user_message",
text: "read this\n\nthen answer",
messageId: "pi-user-0",
},
},
{
@@ -124,4 +126,44 @@ describe("Pi history mapper", () => {
},
]);
});
test("uses Pi tree entry ids for replayed user messages", async () => {
await expect(
collectHistory(
[
{ role: "user", content: "first prompt" },
{ role: "assistant", content: [{ type: "text", text: "first answer" }] },
{ role: "user", content: "second prompt" },
],
[
{ id: "entry-user-1", text: "first prompt" },
{ id: "entry-user-2", text: "second prompt" },
],
),
).resolves.toEqual([
{
type: "timeline",
provider: "pi",
item: {
type: "user_message",
text: "first prompt",
messageId: "entry-user-1",
},
},
{
type: "timeline",
provider: "pi",
item: { type: "assistant_message", text: "first answer" },
},
{
type: "timeline",
provider: "pi",
item: {
type: "user_message",
text: "second prompt",
messageId: "entry-user-2",
},
},
]);
});
});
@@ -8,6 +8,11 @@ import {
type PiTrackedToolCall,
} from "./tool-call-mapper.js";
export interface PiCapturedUserMessageEntry {
id: string;
text: string;
}
function isTextContentBlock(block: unknown): block is PiTextContent {
return (
typeof block === "object" &&
@@ -35,6 +40,7 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon
export async function* streamPiHistory(
provider: string,
messages: PiAgentMessage[],
userEntries: readonly PiCapturedUserMessageEntry[] = [],
): AsyncGenerator<AgentStreamEvent> {
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
let userIndex = 0;
@@ -43,13 +49,14 @@ export async function* streamPiHistory(
if (message.role === "user") {
const text = getUserMessageText(message.content);
if (text) {
const userEntry = userEntries[userIndex];
yield {
type: "timeline",
provider,
item: {
type: "user_message",
text,
messageId: `pi-user-${userIndex}`,
...(userEntry ? { messageId: userEntry.id } : {}),
},
};
}
@@ -0,0 +1,14 @@
export interface PiRewindNavigator {
navigateTree(targetId: string): Promise<unknown>;
}
export async function revertPiConversation(input: {
messageId: string;
navigator: PiRewindNavigator;
}): Promise<void> {
const targetId = input.messageId.trim();
if (!targetId) {
throw new Error("Pi rewind requires a user message id");
}
await input.navigator.navigateTree(targetId);
}
@@ -33,6 +33,10 @@ export type PiAgentMessage =
| {
role: "assistant";
content: PiAssistantContent[];
provider?: string;
model?: string;
responseId?: string;
responseModel?: string;
errorMessage?: string | null;
stopReason?: string;
}
@@ -129,6 +133,8 @@ export type PiAssistantMessageEvent =
export type PiAgentSessionEvent =
| { type: "agent_start" }
| { type: "turn_start" }
| { type: "message_start"; message: PiAgentMessage }
| { type: "message_end"; message: PiAgentMessage }
| {
type: "message_update";
message: PiAgentMessage;
@@ -17,6 +17,7 @@ export interface PiRuntimeLaunch {
session?: string;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
}
export interface PiStartSessionInput {
@@ -27,6 +28,7 @@ export interface PiStartSessionInput {
session?: string;
systemPrompt?: string;
mcpConfigPath?: string;
extensionPaths?: string[];
}
export interface PiRuntimeSession {
@@ -85,6 +87,9 @@ export function buildPiLaunch(input: {
if (input.session.mcpConfigPath) {
argv.push("--mcp-config", input.session.mcpConfigPath);
}
for (const extensionPath of input.session.extensionPaths ?? []) {
argv.push("--extension", extensionPath);
}
return {
cwd: input.session.cwd,
@@ -101,6 +106,7 @@ export function buildPiLaunch(input: {
session: input.session.session,
systemPrompt,
mcpConfigPath: input.session.mcpConfigPath,
extensionPaths: input.session.extensionPaths,
};
}
@@ -1,297 +0,0 @@
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { listPiPersistedAgents } from "./session-descriptor.js";
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
function tempRoot(): string {
return mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-"));
}
function writeJsonl(filePath: string, entries: unknown[]): string {
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
return filePath;
}
function piSession(overrides: {
id: string;
cwd: string;
timestamp?: string;
entries?: unknown[];
}): unknown[] {
return [
{
type: "session",
version: 3,
id: overrides.id,
timestamp: overrides.timestamp ?? "2026-01-01T00:00:00.000Z",
cwd: overrides.cwd,
},
...(overrides.entries ?? []),
];
}
function message(input: {
role: "user" | "assistant";
content: unknown;
timestamp: string;
}): unknown {
return {
type: "message",
id: `entry-${input.timestamp}`,
parentId: null,
timestamp: input.timestamp,
message: {
role: input.role,
content: input.content,
},
};
}
function sessionInfo(name: string, timestamp: string): unknown {
return {
type: "session_info",
id: `info-${timestamp}`,
parentId: null,
timestamp,
name,
};
}
describe("listPiPersistedAgents", () => {
test("lists Pi sessions from the configured agent dir", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const sessionsDir = path.join(root, "agent", "sessions", "--project--");
const sessionFile = writeJsonl(
path.join(sessionsDir, "20260101_session-a.jsonl"),
piSession({
id: "session-a",
cwd,
entries: [
message({
role: "user",
content: "first prompt",
timestamp: "2026-01-01T00:00:01.000Z",
}),
message({
role: "assistant",
content: [{ type: "text", text: "answer" }],
timestamp: "2026-01-01T00:00:02.000Z",
}),
message({
role: "user",
content: [{ type: "text", text: "last prompt" }],
timestamp: "2026-01-01T00:00:03.000Z",
}),
sessionInfo("Named Pi Session", "2026-01-01T00:00:10.000Z"),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors).toEqual([
{
provider: "pi",
sessionId: "session-a",
cwd,
title: "Named Pi Session",
lastActivityAt: new Date("2026-01-01T00:00:03.000Z"),
persistence: {
provider: "pi",
sessionId: "session-a",
nativeHandle: sessionFile,
metadata: {
provider: "pi",
cwd,
},
},
timeline: [
{ type: "user_message", text: "first prompt" },
{ type: "user_message", text: "last prompt" },
],
},
]);
});
test("uses the runtime session-dir override before Pi settings", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const agentDir = path.join(root, "agent");
const runtimeSessionsDir = path.join(root, "runtime-sessions");
const settingsSessionsDir = path.join(root, "settings-sessions");
mkdirSync(agentDir, { recursive: true });
writeFileSync(
path.join(agentDir, "settings.json"),
JSON.stringify({ sessionDir: settingsSessionsDir }),
"utf8",
);
writeJsonl(
path.join(settingsSessionsDir, "ignored.jsonl"),
piSession({ id: "settings-session", cwd }),
);
writeJsonl(
path.join(runtimeSessionsDir, "selected.jsonl"),
piSession({
id: "runtime-session",
cwd,
entries: [
message({
role: "user",
content: "from runtime dir",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
runtimeSettings: {
env: {
PI_CODING_AGENT_DIR: agentDir,
PI_CODING_AGENT_SESSION_DIR: runtimeSessionsDir,
},
},
env: {},
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["runtime-session"]);
});
test("resolves project settings sessionDir relative to the requested cwd", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const projectSettingsDir = path.join(cwd, ".pi");
const sessionsDir = path.join(cwd, "relative-sessions");
mkdirSync(projectSettingsDir, { recursive: true });
writeFileSync(
path.join(projectSettingsDir, "settings.json"),
JSON.stringify({ sessionDir: "relative-sessions" }),
"utf8",
);
writeJsonl(
path.join(sessionsDir, "relative.jsonl"),
piSession({
id: "relative-session",
cwd,
entries: [
message({
role: "user",
content: "relative settings",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["relative-session"]);
});
test("matches sessions stored with the real cwd when the requested cwd is symlinked", async () => {
const root = tempRoot();
const realCwd = path.join(root, "real-project");
const linkedCwd = path.join(root, "linked-project");
mkdirSync(realCwd, { recursive: true });
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
const persistedCwd = realpathSync(linkedCwd);
const sessionsDir = path.join(root, "agent", "sessions");
writeJsonl(
path.join(sessionsDir, "symlinked.jsonl"),
piSession({
id: "symlinked-session",
cwd: persistedCwd,
entries: [
message({
role: "user",
content: "through symlink",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
const descriptors = await listPiPersistedAgents({
cwd: linkedCwd,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["symlinked-session"]);
});
test("filters by cwd, sorts by activity, and ignores invalid session files", async () => {
const root = tempRoot();
const cwd = path.join(root, "project");
const otherCwd = path.join(root, "other");
const sessionsDir = path.join(root, "agent", "sessions");
writeJsonl(
path.join(sessionsDir, "older.jsonl"),
piSession({
id: "older",
cwd,
entries: [
message({
role: "user",
content: "older",
timestamp: "2026-01-01T00:00:01.000Z",
}),
],
}),
);
writeJsonl(
path.join(sessionsDir, "newer.jsonl"),
piSession({
id: "newer",
cwd,
entries: [
message({
role: "user",
content: "newer",
timestamp: "2026-01-01T00:00:02.000Z",
}),
],
}),
);
writeJsonl(
path.join(sessionsDir, "outside.jsonl"),
piSession({
id: "outside",
cwd: otherCwd,
entries: [
message({
role: "user",
content: "outside",
timestamp: "2026-01-01T00:00:03.000Z",
}),
],
}),
);
writeFileSync(path.join(sessionsDir, "broken.jsonl"), '{"type":"nope"}\n', "utf8");
const descriptors = await listPiPersistedAgents({
cwd,
limit: 1,
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
homeDir: root,
});
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["newer"]);
});
});
@@ -1,372 +0,0 @@
import { open, readdir, readFile, stat } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import type {
AgentPersistenceHandle,
AgentTimelineItem,
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "../../agent-sdk-types.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
const PI_PROVIDER = "pi";
const PI_CONFIG_DIR_NAME = ".pi";
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions {
runtimeSettings?: ProviderRuntimeSettings;
env?: NodeJS.ProcessEnv;
homeDir?: string;
}
interface PiSessionHeader {
sessionId: string;
cwd: string;
createdAt: Date | null;
}
interface PiSessionTail {
title: string | null;
lastActivityAt: Date | null;
lastUserMessage: string | null;
}
export async function listPiPersistedAgents(
options: PiSessionDescriptorOptions = {},
): Promise<PersistedAgentDescriptor[]> {
const sessionsDir = await resolvePiSessionsDir(options);
const files = await walkJsonlFiles(sessionsDir);
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
const limit = options.limit ?? 20;
const descriptors: PersistedAgentDescriptor[] = [];
for (const file of files) {
const descriptor = await readPiSessionDescriptor(file);
if (!descriptor) continue;
if (matchesCwd && !matchesCwd(descriptor.cwd)) continue;
descriptors.push(descriptor);
}
return descriptors
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
.slice(0, limit);
}
async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise<string> {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const baseDir = options.cwd ?? process.cwd();
const agentDir = resolvePiAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
const envSessionDir =
options.runtimeSettings?.env?.[PI_SESSION_DIR_ENV] ?? env[PI_SESSION_DIR_ENV];
if (envSessionDir?.trim()) {
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
}
const settingsSessionDir = await readConfiguredSessionDir({
agentDir,
cwd: options.cwd,
});
if (settingsSessionDir?.trim()) {
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
}
return path.join(agentDir, "sessions");
}
function resolvePiAgentDir(input: {
runtimeSettings?: ProviderRuntimeSettings;
env: NodeJS.ProcessEnv;
homeDir: string;
}): string {
const configured = input.runtimeSettings?.env?.[PI_AGENT_DIR_ENV] ?? input.env[PI_AGENT_DIR_ENV];
if (configured?.trim()) {
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
}
return path.join(input.homeDir, PI_CONFIG_DIR_NAME, "agent");
}
async function readConfiguredSessionDir(input: {
agentDir: string;
cwd: string | undefined;
}): Promise<string | null> {
const values = await Promise.all([
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
input.cwd
? readSessionDirFromSettings(path.join(input.cwd, PI_CONFIG_DIR_NAME, "settings.json"))
: null,
]);
return values[1] ?? values[0] ?? null;
}
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
try {
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const sessionDir = Reflect.get(parsed, "sessionDir");
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
} catch {
return null;
}
}
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
if (value === "~") {
return options.homeDir;
}
if (value.startsWith("~/")) {
return path.join(options.homeDir, value.slice(2));
}
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
}
async function walkJsonlFiles(root: string): Promise<string[]> {
let entries: import("node:fs").Dirent[];
try {
entries = await readdir(root, { withFileTypes: true });
} catch {
return [];
}
const files = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(root, entry.name);
if (entry.isDirectory()) {
return await walkJsonlFiles(entryPath);
}
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
}),
);
return files.flat();
}
async function readPiSessionDescriptor(filePath: string): Promise<PersistedAgentDescriptor | null> {
const firstLine = await readFirstLine(filePath);
if (!firstLine) return null;
const header = parseSessionHeader(firstLine);
if (!header) return null;
const tail = await readTail(filePath).catch(() => "");
const tailInfo = parseSessionTail(tail);
const headInfo = await scanSessionHead(filePath);
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
const lastActivityAt =
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
const timeline = buildPreviewTimeline({
firstUserMessage: headInfo.firstUserMessage,
lastUserMessage: tailInfo.lastUserMessage,
});
const persistence: AgentPersistenceHandle = {
provider: PI_PROVIDER,
sessionId: header.sessionId,
nativeHandle: filePath,
metadata: {
provider: PI_PROVIDER,
cwd: header.cwd,
},
};
return {
provider: PI_PROVIDER,
sessionId: header.sessionId,
cwd: header.cwd,
title,
lastActivityAt,
persistence,
timeline,
};
}
async function readFirstLine(filePath: string): Promise<string | null> {
const handle = await open(filePath, "r").catch(() => null);
if (!handle) return null;
try {
const buffer = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
if (bytesRead <= 0) return null;
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
const newlineIndex = chunk.indexOf("\n");
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
} finally {
await handle.close().catch(() => undefined);
}
}
async function readTail(filePath: string): Promise<string> {
const fileStats = await stat(filePath);
const start = Math.max(0, fileStats.size - TAIL_BYTES);
const length = fileStats.size - start;
const handle = await open(filePath, "r");
try {
const buffer = Buffer.alloc(length);
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await handle.close().catch(() => undefined);
}
}
async function readFileMtime(filePath: string): Promise<Date | null> {
try {
return (await stat(filePath)).mtime;
} catch {
return null;
}
}
function parseSessionHeader(firstLine: string): PiSessionHeader | null {
const entry = parseJsonRecord(firstLine);
if (!entry || entry.type !== "session") return null;
const sessionId = typeof entry.id === "string" ? entry.id : null;
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
if (!sessionId || !cwd) return null;
const createdAt = parseDate(entry.timestamp);
return { sessionId, cwd, createdAt };
}
function parseSessionTail(tail: string): PiSessionTail {
const lines = tail.split(/\r?\n/);
let title: string | null = null;
let lastActivityAt: Date | null = null;
let fallbackTimestamp: Date | null = null;
let lastUserMessage: string | null = null;
for (let index = lines.length - 1; index >= 0; index -= 1) {
const entry = parseJsonRecord(lines[index].trim());
if (!entry) continue;
if (!title && entry.type === "session_info") {
title = readNonEmptyString(entry.name);
}
const entryTimestamp = parseDate(entry.timestamp);
if (!fallbackTimestamp && entryTimestamp) {
fallbackTimestamp = entryTimestamp;
}
if (entry.type !== "message") {
continue;
}
if (!lastActivityAt && entryTimestamp) {
lastActivityAt = entryTimestamp;
}
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
lastUserMessage = extractMessageText(entry.message.content);
}
if (title && lastActivityAt && lastUserMessage) {
break;
}
}
return { title, lastActivityAt: lastActivityAt ?? fallbackTimestamp, lastUserMessage };
}
async function scanSessionHead(filePath: string): Promise<{
title: string | null;
firstUserMessage: string | null;
}> {
let content: string;
try {
content = await readFile(filePath, "utf8");
} catch {
return { title: null, firstUserMessage: null };
}
let title: string | null = null;
let firstUserMessage: string | null = null;
let lineCount = 0;
for (const rawLine of content.split(/\r?\n/)) {
lineCount += 1;
const entry = parseJsonRecord(rawLine.trim());
if (!entry) continue;
if (entry.type === "session_info") {
title = readNonEmptyString(entry.name) ?? title;
}
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
if (entry.message.role === "user") {
firstUserMessage = extractMessageText(entry.message.content);
}
}
if (title && firstUserMessage) {
break;
}
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
break;
}
}
return { title, firstUserMessage };
}
function buildPreviewTimeline(input: {
firstUserMessage: string | null;
lastUserMessage: string | null;
}): AgentTimelineItem[] {
const items: AgentTimelineItem[] = [];
if (input.firstUserMessage) {
items.push({ type: "user_message", text: input.firstUserMessage });
}
if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) {
items.push({ type: "user_message", text: input.lastUserMessage });
}
return items;
}
function parseJsonRecord(line: string): Record<string, unknown> | null {
if (!line) return null;
try {
const parsed = JSON.parse(line) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function parseDate(value: unknown): Date | null {
if (typeof value !== "string" && typeof value !== "number") {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function extractMessageText(content: unknown): string | null {
if (typeof content === "string") {
return content.trim() || null;
}
if (!Array.isArray(content)) {
return null;
}
const text = content
.flatMap((part) =>
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
)
.join("\n\n")
.trim();
return text || null;
}
@@ -53,6 +53,8 @@ export class FakePiSession implements PiRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
readonly setThinkingLevelRequests: string[] = [];
readonly treeNavigationRequests: string[] = [];
capturedUserEntries: Array<{ id: string; parentId: string | null; text: string }> = [];
abortRequested = false;
readonly canceledExtensionUiRequests: string[] = [];
readonly extensionUiResponses: Array<{
@@ -77,7 +79,7 @@ export class FakePiSession implements PiRuntimeSession {
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
sessionFile: launch.session ?? "/tmp/pi-session.jsonl",
sessionFile: launch.session ?? "/tmp/pi-session",
sessionId: "pi-session-1",
messageCount: 0,
pendingMessageCount: 0,
@@ -96,6 +98,8 @@ export class FakePiSession implements PiRuntimeSession {
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void> {
this.prompts.push({ message, imageCount: images?.length ?? 0 });
this.handleTreeNavigationCommand(message);
this.handleEntryCaptureCommand(message);
}
async abort(): Promise<void> {
@@ -158,4 +162,62 @@ export class FakePiSession implements PiRuntimeSession {
this.messages = [...this.messages, message];
this.emit({ type: "agent_end", messages: this.messages });
}
private handleTreeNavigationCommand(message: string): void {
const prefix = "/paseo_tree ";
if (!message.startsWith(prefix)) {
return;
}
const payload = JSON.parse(
Buffer.from(message.slice(prefix.length), "base64url").toString("utf8"),
) as { targetId?: unknown; requestId?: unknown };
if (typeof payload.targetId !== "string" || typeof payload.requestId !== "string") {
return;
}
this.treeNavigationRequests.push(payload.targetId);
this.emitEntryCapture(undefined, "tree_navigation");
this.emitExtensionCommandResult(payload.requestId, { ok: true, result: {} });
}
private handleEntryCaptureCommand(message: string): void {
const prefix = "/paseo_capture_entries ";
if (!message.startsWith(prefix)) {
return;
}
const payload = JSON.parse(
Buffer.from(message.slice(prefix.length), "base64url").toString("utf8"),
) as { requestId?: unknown; reason?: unknown };
if (typeof payload.requestId !== "string") {
return;
}
this.emitEntryCapture(
payload.requestId,
typeof payload.reason === "string" ? payload.reason : "command",
);
}
emitEntryCapture(requestId?: string, reason = "test"): void {
this.emit({
type: "extension_ui_request",
id: `capture-${requestId ?? reason}`,
method: "notify",
message: `PASEO_ENTRY_CAPTURE ${JSON.stringify({
reason,
requestId,
entries: this.capturedUserEntries,
})}`,
});
}
private emitExtensionCommandResult(
requestId: string,
result: { ok: true; result: unknown } | { ok: false; error: string },
): void {
this.emit({
type: "extension_ui_request",
id: `command-${requestId}`,
method: "notify",
message: `PASEO_COMMAND_RESULT ${JSON.stringify({ requestId, ...result })}`,
});
}
}
@@ -9,7 +9,7 @@ import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { ClaudeAgentClient } from "./claude/agent.js";
import { __codexAppServerInternals, CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { CodexAppServerAgentClient, findDefaultCodexBinary } from "./codex-app-server-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
const originalEnv = {
@@ -88,7 +88,7 @@ describe("default provider availability", () => {
try {
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(__codexAppServerInternals.findDefaultCodexBinary()).resolves.toBe(codexExe);
await expect(findDefaultCodexBinary()).resolves.toBe(codexExe);
await expect(client.isAvailable()).resolves.toBe(true);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
@@ -0,0 +1,118 @@
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { AgentManager } from "../agent-manager.js";
import type {
AgentClient,
AgentSession,
AgentSessionConfig,
ListModelsOptions,
} from "../agent-sdk-types.js";
import { FakeRewindSession, REWIND_TEST_CAPABILITIES } from "./test-rewind-session.js";
class FakeRewindClient implements AgentClient {
readonly provider = "claude";
readonly capabilities = REWIND_TEST_CAPABILITIES;
constructor(readonly session: FakeRewindSession) {}
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
return this.session;
}
async resumeSession(): Promise<AgentSession> {
return this.session;
}
async listModels(_options: ListModelsOptions) {
return [];
}
async isAvailable() {
return true;
}
}
class RewindHistoryGate {
private gate: Promise<void> | null = null;
private releaseGate: (() => void) | null = null;
hold(): void {
this.gate = new Promise<void>((resolve) => {
this.releaseGate = resolve;
});
}
release(): void {
this.releaseGate?.();
this.releaseGate = null;
this.gate = null;
}
async wait(): Promise<void> {
await this.gate;
}
}
async function createRewindHarness(options: { historyGate?: RewindHistoryGate } = {}) {
const session = new FakeRewindSession(options.historyGate?.wait.bind(options.historyGate));
const manager = new AgentManager({
clients: { claude: new FakeRewindClient(session) },
logger: createTestLogger(),
idFactory: () => "00000000-0000-4000-8000-000000000901",
});
const agent = await manager.createAgent({
provider: "claude",
cwd: process.cwd(),
});
return { manager, session, agentId: agent.id };
}
describe("AgentManager rewind", () => {
test("rewinds the conversation and rehydrates the timeline", async () => {
const { manager, session, agentId } = await createRewindHarness();
await manager.rewind(agentId, "message-1", "conversation");
expect(session.recordedRewinds).toEqual([{ mode: "conversation", messageId: "message-1" }]);
expect(session.historyReadCount).toBe(1);
expect(manager.fetchTimeline(agentId, { limit: 0 }).rows.map((row) => row.item)).toEqual([
{ type: "user_message", text: "before", messageId: "message-1" },
]);
});
test("rewinds files without rehydrating the conversation timeline", async () => {
const { manager, session, agentId } = await createRewindHarness();
await manager.rewind(agentId, "message-1", "files");
expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "message-1" }]);
expect(session.historyReadCount).toBe(0);
});
test("aborts an in-flight turn before rewinding", async () => {
const { manager, session, agentId } = await createRewindHarness();
const run = manager.streamAgent(agentId, "keep working");
await run.next();
await manager.rewind(agentId, "message-1", "files");
expect(session.aborted).toBe(true);
expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "message-1" }]);
});
test("blocks new prompts until the rehydrate epoch broadcasts", async () => {
const historyGate = new RewindHistoryGate();
historyGate.hold();
const { manager, agentId } = await createRewindHarness({ historyGate });
const rewind = manager.rewind(agentId, "message-1", "both");
expect(() => manager.streamAgent(agentId, "too early")).toThrow(
"Agent 00000000-0000-4000-8000-000000000901 already has an active run",
);
historyGate.release();
await rewind;
});
});
@@ -0,0 +1,36 @@
import type { AgentSession } from "../agent-sdk-types.js";
export type RewindMode = "conversation" | "files" | "both";
export class RewindCapabilityError extends Error {
constructor(mode: RewindMode) {
super(`Provider does not support rewinding ${mode}`);
this.name = "RewindCapabilityError";
}
}
export async function invokeRewindCapability(
session: AgentSession,
input: { messageId: string; mode: RewindMode },
): Promise<void> {
switch (input.mode) {
case "conversation":
if (!session.capabilities.supportsRewindConversation || !session.revertConversation) {
throw new RewindCapabilityError(input.mode);
}
await session.revertConversation({ messageId: input.messageId });
return;
case "files":
if (!session.capabilities.supportsRewindFiles || !session.revertFiles) {
throw new RewindCapabilityError(input.mode);
}
await session.revertFiles({ messageId: input.messageId });
return;
case "both":
if (!session.capabilities.supportsRewindBoth || !session.revertBoth) {
throw new RewindCapabilityError(input.mode);
}
await session.revertBoth({ messageId: input.messageId });
return;
}
}
@@ -0,0 +1,125 @@
import type {
AgentCapabilityFlags,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
AgentProvider,
AgentRunResult,
AgentSession,
AgentStreamEvent,
AgentTimelineItem,
} from "../agent-sdk-types.js";
export const REWIND_TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsRewindBoth: true,
};
export interface RecordedRewind {
mode: "conversation" | "files" | "both";
messageId: string;
}
export class FakeRewindSession implements AgentSession {
readonly provider: AgentProvider = "claude";
readonly id = "fake-rewind-session";
readonly capabilities = REWIND_TEST_CAPABILITIES;
readonly recordedRewinds: RecordedRewind[] = [];
aborted = false;
historyReadCount = 0;
history: AgentTimelineItem[] = [{ type: "user_message", text: "before", messageId: "message-1" }];
private subscribers = new Set<(event: AgentStreamEvent) => void>();
private activeTurnId: string | null = null;
constructor(private readonly waitBeforeHistory?: () => Promise<void>) {}
async run(): Promise<AgentRunResult> {
return { sessionId: this.id, finalText: "", timeline: [] };
}
async startTurn(): Promise<{ turnId: string }> {
this.aborted = false;
this.activeTurnId = "turn-1";
queueMicrotask(() => {
this.emit({ type: "turn_started", provider: this.provider, turnId: "turn-1" });
});
return { turnId: "turn-1" };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
this.historyReadCount += 1;
await this.waitBeforeHistory?.();
for (const item of this.history) {
yield { type: "timeline", provider: this.provider, item };
}
}
async getRuntimeInfo() {
return { provider: this.provider, sessionId: this.id };
}
async getAvailableModes() {
return [];
}
async getCurrentMode() {
return null;
}
async setMode(): Promise<void> {}
getPendingPermissions(): AgentPermissionRequest[] {
return [];
}
async respondToPermission(
_requestId: string,
_response: AgentPermissionResponse,
): Promise<void> {}
describePersistence(): AgentPersistenceHandle {
return { provider: this.provider, sessionId: this.id };
}
async interrupt(): Promise<void> {
this.aborted = true;
if (this.activeTurnId) {
const turnId = this.activeTurnId;
this.activeTurnId = null;
this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
}
}
async close(): Promise<void> {}
async revertConversation(input: { messageId: string }): Promise<void> {
this.recordedRewinds.push({ mode: "conversation", messageId: input.messageId });
}
async revertFiles(input: { messageId: string }): Promise<void> {
this.recordedRewinds.push({ mode: "files", messageId: input.messageId });
}
async revertBoth(input: { messageId: string }): Promise<void> {
this.recordedRewinds.push({ mode: "both", messageId: input.messageId });
}
private emit(event: AgentStreamEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
}
@@ -0,0 +1,341 @@
import { readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig } from "./agent-configs.js";
import {
closeRewindSession,
fetchTimelineItems,
fileExists,
tmpRewindCwd,
userMessageIdForToken,
} from "./test-utils/rewind-helpers.js";
interface ClaudeRewindHarness {
client: DaemonClient;
daemon: TestPaseoDaemon;
}
interface ClaudeRewindSession {
agentId: string;
cwd: string;
}
interface ClaudeTurnSpec {
index: 1 | 2 | 3;
promptToken: string;
doneToken: string;
fileName: string;
content: string;
}
interface RewindCase {
name: string;
turnCount: 1 | 2 | 3;
rewindTurn: 1 | 2 | 3;
mode: "both" | "conversation" | "files";
}
const TURN_TIMEOUT_MS = 180_000;
const MATRIX: RewindCase[] = [
{
name: "single-turn session, rewind only user message, both",
turnCount: 1,
rewindTurn: 1,
mode: "both",
},
{ name: "two-turn session, rewind user #1, both", turnCount: 2, rewindTurn: 1, mode: "both" },
{ name: "two-turn session, rewind user #2, both", turnCount: 2, rewindTurn: 2, mode: "both" },
{ name: "three-turn session, rewind user #1, both", turnCount: 3, rewindTurn: 1, mode: "both" },
{ name: "three-turn session, rewind user #2, both", turnCount: 3, rewindTurn: 2, mode: "both" },
{ name: "three-turn session, rewind user #3, both", turnCount: 3, rewindTurn: 3, mode: "both" },
{
name: "single-turn session, rewind only user message, conversation",
turnCount: 1,
rewindTurn: 1,
mode: "conversation",
},
{
name: "two-turn session, rewind user #1, conversation",
turnCount: 2,
rewindTurn: 1,
mode: "conversation",
},
{
name: "two-turn session, rewind user #2, conversation",
turnCount: 2,
rewindTurn: 2,
mode: "conversation",
},
{
name: "three-turn session, rewind user #1, conversation",
turnCount: 3,
rewindTurn: 1,
mode: "conversation",
},
{
name: "three-turn session, rewind user #2, conversation",
turnCount: 3,
rewindTurn: 2,
mode: "conversation",
},
{
name: "three-turn session, rewind user #3, conversation",
turnCount: 3,
rewindTurn: 3,
mode: "conversation",
},
{
name: "single-turn session, rewind only user message, files",
turnCount: 1,
rewindTurn: 1,
mode: "files",
},
{ name: "two-turn session, rewind user #1, files", turnCount: 2, rewindTurn: 1, mode: "files" },
{ name: "two-turn session, rewind user #2, files", turnCount: 2, rewindTurn: 2, mode: "files" },
{ name: "three-turn session, rewind user #1, files", turnCount: 3, rewindTurn: 1, mode: "files" },
{ name: "three-turn session, rewind user #2, files", turnCount: 3, rewindTurn: 2, mode: "files" },
{ name: "three-turn session, rewind user #3, files", turnCount: 3, rewindTurn: 3, mode: "files" },
];
async function launchClaudeRewindSession(
harness: ClaudeRewindHarness,
title: string,
): Promise<ClaudeRewindSession> {
const cwd = tmpRewindCwd("daemon-real-claude-rewind-");
await writeFile(path.join(cwd, "baseline.txt"), "BASE\n", "utf8");
const agent = await harness.client.createAgent({
cwd,
title,
...getFullAccessConfig("claude"),
});
return { agentId: agent.id, cwd };
}
async function closeClaudeRewindSession(session: ClaudeRewindSession): Promise<void> {
closeRewindSession(session);
}
function buildTurns(scenario: RewindCase): ClaudeTurnSpec[] {
const prefix = `${scenario.mode.toUpperCase()}_${scenario.turnCount}_${scenario.rewindTurn}`;
return Array.from({ length: scenario.turnCount }, (_, offset) => {
const index = (offset + 1) as 1 | 2 | 3;
return {
index,
promptToken: `PASEO_RW_${prefix}_T${index}`,
doneToken: `PASEO_RW_${prefix}_T${index}_DONE`,
fileName: `turn-${index}.txt`,
content: `turn ${index} preserved content\n`,
};
});
}
function editPrompt(turn: ClaudeTurnSpec): string {
return [
`${turn.promptToken}.`,
`Use the Write tool, not Bash, to create ${turn.fileName} with exactly:`,
"```",
turn.content.trimEnd(),
"```",
`When the file is saved, reply exactly: ${turn.doneToken}`,
].join("\n");
}
async function sendClaudeWriteTurn(
harness: ClaudeRewindHarness,
session: ClaudeRewindSession,
turn: ClaudeTurnSpec,
): Promise<void> {
await harness.client.sendMessage(session.agentId, editPrompt(turn));
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
await expect(fileExists(path.join(session.cwd, turn.fileName))).resolves.toBe(true);
}
async function sendClaudeReplyTurn(
harness: ClaudeRewindHarness,
session: ClaudeRewindSession,
prompt: string,
): Promise<void> {
await harness.client.sendMessage(session.agentId, prompt);
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
}
async function runtimeSessionId(
harness: ClaudeRewindHarness,
session: ClaudeRewindSession,
): Promise<string | null> {
const snapshot = await harness.client.fetchAgent(session.agentId);
return snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId ?? null;
}
function roleItems(items: AgentTimelineItem[], role: "user_message" | "assistant_message") {
return items.filter((item) => item.type === role);
}
function expectSessionId(value: string | null): asserts value is string {
expect(value).toMatch(/^[a-f0-9-]{36}$/);
}
async function createdFilesAtCwd(session: ClaudeRewindSession): Promise<string[]> {
const entries = await readdir(session.cwd);
return entries.filter((name) => name === "baseline.txt" || /^turn-\d+\.txt$/u.test(name)).sort();
}
async function assertFiles(
session: ClaudeRewindSession,
expectedPresent: ClaudeTurnSpec[],
): Promise<void> {
await expect(createdFilesAtCwd(session)).resolves.toEqual(
["baseline.txt", ...expectedPresent.map((turn) => turn.fileName)].sort(),
);
}
function assertTimeline(items: AgentTimelineItem[], expectedKept: ClaudeTurnSpec[]): void {
const userItems = roleItems(items, "user_message");
const assistantItems = roleItems(items, "assistant_message");
expect(userItems).toHaveLength(expectedKept.length);
expect(userItems.map((item) => item.text)).toEqual(expectedKept.map(editPrompt));
expect(assistantItems).toHaveLength(expectedKept.length);
}
describe("daemon E2E (real claude) - rewind", () => {
let harness: ClaudeRewindHarness;
beforeAll(async () => {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { claude: new ClaudeAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.70",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "claude-rewind-real" } });
harness = { client, daemon };
}, 30_000);
afterAll(async () => {
await harness?.client.close().catch(() => undefined);
await harness?.daemon.close().catch(() => undefined);
});
test.each(MATRIX)(
"$name",
async (scenario) => {
const session = await launchClaudeRewindSession(
harness,
`claude-rewind-${scenario.mode}-${scenario.turnCount}-${scenario.rewindTurn}`,
);
const turns = buildTurns(scenario);
try {
for (const turn of turns) {
await sendClaudeWriteTurn(harness, session, turn);
}
const beforeTimeline = await fetchTimelineItems(harness.client, session.agentId);
const targetTurn = turns[scenario.rewindTurn - 1];
const targetMessageId = userMessageIdForToken(beforeTimeline, targetTurn.promptToken);
const sessionIdBefore = await runtimeSessionId(harness, session);
expectSessionId(sessionIdBefore);
await harness.client.rewindAgent(session.agentId, targetMessageId, scenario.mode);
const afterTimeline = await fetchTimelineItems(harness.client, session.agentId);
const sessionIdAfter = await runtimeSessionId(harness, session);
const expectedKeptConversation =
scenario.mode === "files" ? turns : turns.slice(0, scenario.rewindTurn - 1);
const expectedKeptFiles =
scenario.mode === "conversation" ? turns : turns.slice(0, scenario.rewindTurn - 1);
assertTimeline(afterTimeline, expectedKeptConversation);
await assertFiles(session, expectedKeptFiles);
if (scenario.mode === "files") {
expect(sessionIdAfter).toBe(sessionIdBefore);
} else {
expectSessionId(sessionIdAfter);
expect(sessionIdAfter).not.toBe(sessionIdBefore);
}
const snapshot = await harness.client.fetchAgent(session.agentId);
expect(snapshot?.agent.status).toBe("idle");
expect(snapshot?.agent.pendingPermissions).toEqual([]);
} finally {
await closeClaudeRewindSession(session);
}
},
420_000,
);
test("emits only the deliberate fork session switch after conversation rewind and continue", async () => {
const session = await launchClaudeRewindSession(harness, "claude-rewind-no-session-roundtrip");
const sessionSwitches: string[] = [];
const unsubscribe = harness.client.on((event) => {
if (event.type !== "agent_stream" || event.agentId !== session.agentId) {
return;
}
if (
event.event.type === "timeline" &&
event.event.item.type === "assistant_message" &&
event.event.item.text.startsWith("Claude switched to a new session:")
) {
sessionSwitches.push(event.event.item.text);
}
});
try {
await sendClaudeReplyTurn(
harness,
session,
"PASEO_RW_NO_ROUNDTRIP_T1. Reply exactly: PASEO_RW_NO_ROUNDTRIP_T1_DONE",
);
await sendClaudeReplyTurn(
harness,
session,
"PASEO_RW_NO_ROUNDTRIP_T2. Reply exactly: PASEO_RW_NO_ROUNDTRIP_T2_DONE",
);
const beforeTimeline = await fetchTimelineItems(harness.client, session.agentId);
const targetMessageId = userMessageIdForToken(beforeTimeline, "PASEO_RW_NO_ROUNDTRIP_T2");
const sessionIdBeforeRewind = await runtimeSessionId(harness, session);
expectSessionId(sessionIdBeforeRewind);
await harness.client.rewindAgent(session.agentId, targetMessageId, "conversation");
const forkedSessionId = await runtimeSessionId(harness, session);
expectSessionId(forkedSessionId);
expect(forkedSessionId).not.toBe(sessionIdBeforeRewind);
await sendClaudeReplyTurn(
harness,
session,
"PASEO_RW_NO_ROUNDTRIP_T3. Reply exactly: PASEO_RW_NO_ROUNDTRIP_T3_DONE",
);
const finalSessionId = await runtimeSessionId(harness, session);
expect(finalSessionId).toBe(forkedSessionId);
expect(sessionSwitches).toEqual([
`Claude switched to a new session: ${sessionIdBeforeRewind} -> ${forkedSessionId}`,
]);
} finally {
unsubscribe();
await closeClaudeRewindSession(session);
}
}, 420_000);
});
@@ -0,0 +1,251 @@
import { readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig } from "./agent-configs.js";
import {
closeRewindSession,
fetchTimelineItems,
fileExists,
readScratchFile,
tmpRewindCwd,
userMessageIdForToken,
} from "./test-utils/rewind-helpers.js";
interface CodexRewindHarness {
client: DaemonClient;
daemon: TestPaseoDaemon;
}
interface CodexRewindSession {
agentId: string;
cwd: string;
scratchPath: string;
}
const TURN_TIMEOUT_MS = 180_000;
async function fetchThreadId(client: DaemonClient, agentId: string): Promise<string> {
const result = await client.fetchAgent(agentId);
const threadId = result?.agent.persistence?.sessionId ?? result?.agent.runtimeInfo?.sessionId;
if (!threadId) {
throw new Error(`Agent ${agentId} does not have a visible Codex thread id`);
}
return threadId;
}
function expectThreadId(value: string): void {
expect(value).toMatch(/^[a-f0-9-]{36}$/);
}
function roleItems(items: AgentTimelineItem[], role: "user_message" | "assistant_message") {
return items.filter((item) => item.type === role);
}
function expectTimeline(
items: AgentTimelineItem[],
input: { userTexts: string[]; assistantCount: number },
): void {
const userMessages = roleItems(items, "user_message");
const assistantMessages = roleItems(items, "assistant_message");
expect(userMessages).toHaveLength(input.userTexts.length);
expect(userMessages.map((message) => message.text)).toEqual(input.userTexts);
expect(assistantMessages).toHaveLength(input.assistantCount);
}
async function createdFilesAtCwd(session: CodexRewindSession): Promise<string[]> {
const entries = await readdir(session.cwd);
return entries
.filter((name) => name === "rewind-scratch.txt" || name === "codex-dummy.txt")
.sort();
}
async function expectCreatedFiles(
session: CodexRewindSession,
expectedNames: string[],
): Promise<void> {
await expect(createdFilesAtCwd(session)).resolves.toEqual([...expectedNames].sort());
}
async function launchCodexRewindSession(
harness: CodexRewindHarness,
title: string,
): Promise<CodexRewindSession> {
const cwd = tmpRewindCwd("daemon-real-codex-rewind-");
const scratchPath = path.join(cwd, "rewind-scratch.txt");
await writeFile(scratchPath, "BASE\n", "utf8");
const agent = await harness.client.createAgent({
cwd,
title,
...getFullAccessConfig("codex"),
});
return { agentId: agent.id, cwd, scratchPath };
}
async function closeCodexRewindSession(session: CodexRewindSession): Promise<void> {
closeRewindSession(session);
}
function editPrompt(input: {
fileName: string;
promptToken: string;
content: string;
doneToken: string;
}): string {
return [
`PASEO_CODEX_REWIND_PROMPT_${input.promptToken}.`,
`Use apply_patch to make ${input.fileName} contain exactly:`,
"```",
input.content.trimEnd(),
"```",
`When the file is saved, reply exactly: ${input.doneToken}`,
].join("\n");
}
function singleCreatePrompt(): string {
return [
"PASEO_CODEX_REWIND_PROMPT_SINGLE_CREATE.",
"Use apply_patch to create codex-dummy.txt with exactly CODEX_CREATED.",
"When the file is saved, reply exactly: CODEX_SINGLE_CREATE_DONE",
].join("\n");
}
async function askCodexToEditFile(
harness: CodexRewindHarness,
session: CodexRewindSession,
input: {
promptToken: string;
content: string;
doneToken: string;
},
): Promise<void> {
await harness.client.sendMessage(
session.agentId,
editPrompt({
fileName: path.basename(session.scratchPath),
promptToken: input.promptToken,
content: input.content,
doneToken: input.doneToken,
}),
);
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
}
describe("daemon E2E (real codex) - rewind", () => {
let harness: CodexRewindHarness;
beforeAll(async () => {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { codex: new CodexAppServerAgentClient(logger) },
logger,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.70",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "codex-rewind-real" } });
harness = { client, daemon };
}, 30_000);
afterAll(async () => {
await harness?.client.close().catch(() => undefined);
await harness?.daemon.close().catch(() => undefined);
});
test("rewinds a real Codex conversation after a single edit turn while leaving the file edit on disk", async () => {
const session = await launchCodexRewindSession(harness, "codex-rewind-single-edit-real");
try {
await askCodexToEditFile(harness, session, {
promptToken: "SINGLE",
content: "BASE\nCODEX_SINGLE_MARKER\n",
doneToken: "CODEX_SINGLE_DONE",
});
const timeline = await fetchTimelineItems(harness.client, session.agentId);
const messageId = userMessageIdForToken(timeline, "CODEX_REWIND_PROMPT_SINGLE");
await harness.client.rewindAgent(session.agentId, messageId, "conversation");
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
const fileText = await readScratchFile(session);
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
expect(fileText).toBe("BASE\nCODEX_SINGLE_MARKER\n");
} finally {
await closeCodexRewindSession(session);
}
}, 420_000);
test("rewinds a real Codex conversation after a single file-creation turn while leaving the created file on disk", async () => {
const session = await launchCodexRewindSession(harness, "codex-rewind-single-create-real");
const dummyPath = path.join(session.cwd, "codex-dummy.txt");
try {
await harness.client.sendMessage(session.agentId, singleCreatePrompt());
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
await expect(fileExists(dummyPath)).resolves.toBe(true);
const timeline = await fetchTimelineItems(harness.client, session.agentId);
const messageId = userMessageIdForToken(timeline, "CODEX_REWIND_PROMPT_SINGLE_CREATE");
await harness.client.rewindAgent(session.agentId, messageId, "conversation");
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
await expect(fileExists(dummyPath)).resolves.toBe(true);
await expectCreatedFiles(session, ["codex-dummy.txt", "rewind-scratch.txt"]);
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
} finally {
await closeCodexRewindSession(session);
}
}, 420_000);
test("rewinds a real Codex conversation to a forked thread while leaving file edits on disk", async () => {
const session = await launchCodexRewindSession(harness, "codex-rewind-conversation-real");
try {
await askCodexToEditFile(harness, session, {
promptToken: "FIRST",
content: "BASE\nCODEX_FIRST_MARKER\n",
doneToken: "CODEX_FIRST_DONE",
});
const firstTimeline = await fetchTimelineItems(harness.client, session.agentId);
const firstMessageId = userMessageIdForToken(firstTimeline, "CODEX_REWIND_PROMPT_FIRST");
await askCodexToEditFile(harness, session, {
promptToken: "SECOND",
content: "BASE\nCODEX_FIRST_MARKER\nCODEX_SECOND_MARKER\n",
doneToken: "CODEX_SECOND_DONE",
});
const oldThreadId = await fetchThreadId(harness.client, session.agentId);
expectThreadId(oldThreadId);
await harness.client.rewindAgent(session.agentId, firstMessageId, "conversation");
const newThreadId = await fetchThreadId(harness.client, session.agentId);
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
const fileText = await readScratchFile(session);
expect(newThreadId).not.toBe(oldThreadId);
expectThreadId(newThreadId);
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
expect(fileText).toBe("BASE\nCODEX_FIRST_MARKER\nCODEX_SECOND_MARKER\n");
} finally {
await closeCodexRewindSession(session);
}
}, 420_000);
});
@@ -0,0 +1,442 @@
import { execFileSync } from "node:child_process";
import { readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentLifecycleStatus } from "../../shared/agent-lifecycle.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig } from "./agent-configs.js";
import {
closeRewindSession,
fetchTimelineItems,
fileExists,
readScratchFile,
tmpRewindCwd,
userMessageIdForToken,
} from "./test-utils/rewind-helpers.js";
interface OpenCodeRewindHarness {
client: DaemonClient;
daemon: TestPaseoDaemon;
}
interface OpenCodeRewindSession {
agentId: string;
cwd: string;
scratchPath: string;
}
interface AgentStatusRecord {
at: number;
status: AgentLifecycleStatus;
}
const TURN_TIMEOUT_MS = 180_000;
async function launchOpenCodeRewindSession(
harness: OpenCodeRewindHarness,
title: string,
): Promise<OpenCodeRewindSession> {
const cwd = tmpRewindCwd("daemon-real-opencode-rewind-", { realpath: true });
const scratchPath = path.join(cwd, "rewind-scratch.txt");
execFileSync("git", ["init"], { cwd, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
cwd,
stdio: "ignore",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd, stdio: "ignore" });
await writeFile(scratchPath, "BASE\n", "utf8");
execFileSync("git", ["add", "rewind-scratch.txt"], { cwd, stdio: "ignore" });
execFileSync("git", ["commit", "-m", "base"], { cwd, stdio: "ignore" });
const agent = await harness.client.createAgent({
cwd,
title,
...getFullAccessConfig("opencode"),
});
return { agentId: agent.id, cwd, scratchPath };
}
async function runtimeSessionId(
harness: OpenCodeRewindHarness,
session: OpenCodeRewindSession,
): Promise<string> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const sessionId =
snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId;
if (!sessionId) {
throw new Error(`Agent ${session.agentId} does not have a visible OpenCode session id`);
}
return sessionId;
}
function expectOpenCodeSessionId(value: string): void {
expect(value).toMatch(/^ses_/);
}
function roleItems(items: AgentTimelineItem[], role: "user_message" | "assistant_message") {
return items.filter((item) => item.type === role);
}
function expectTimeline(
items: AgentTimelineItem[],
input: { userTexts: string[]; assistantCount: number },
): void {
const userMessages = roleItems(items, "user_message");
const assistantMessages = roleItems(items, "assistant_message");
expect(userMessages).toHaveLength(input.userTexts.length);
expect(userMessages.map((message) => message.text)).toEqual(input.userTexts);
expect(assistantMessages).toHaveLength(input.assistantCount);
}
function expectUserMessageCount(items: AgentTimelineItem[], expected: number): void {
expect(roleItems(items, "user_message")).toHaveLength(expected);
}
async function recordAgentStatusTransitions(
harness: OpenCodeRewindHarness,
session: OpenCodeRewindSession,
): Promise<{ records: AgentStatusRecord[]; stop: () => void }> {
const records: AgentStatusRecord[] = [];
const initial = await harness.client.fetchAgent(session.agentId);
if (initial?.agent.status) {
records.push({ at: Date.now(), status: initial.agent.status });
}
const unsubscribe = harness.client.on("agent_update", (message) => {
if (message.payload.kind !== "upsert" || message.payload.agent.id !== session.agentId) {
return;
}
records.push({ at: Date.now(), status: message.payload.agent.status });
});
return { records, stop: unsubscribe };
}
function screenStatusForInvariant(status: AgentLifecycleStatus): "loading" | AgentLifecycleStatus {
return status === "initializing" ? "loading" : status;
}
function expectNoLoadingRegression(records: AgentStatusRecord[]): void {
const statuses = records.map((record) => screenStatusForInvariant(record.status));
for (let index = 1; index < statuses.length; index += 1) {
expect([statuses[index - 1], statuses[index]]).not.toEqual(["running", "loading"]);
}
for (let index = 2; index < statuses.length; index += 1) {
expect([statuses[index - 2], statuses[index - 1], statuses[index]]).not.toEqual([
"idle",
"loading",
"idle",
]);
}
}
async function expectAgentIdle(
harness: OpenCodeRewindHarness,
session: OpenCodeRewindSession,
): Promise<void> {
const snapshot = await harness.client.fetchAgent(session.agentId);
expect(snapshot?.agent.status).toBe("idle");
}
async function createdFilesAtCwd(session: OpenCodeRewindSession): Promise<string[]> {
const entries = await readdir(session.cwd);
return entries
.filter((name) =>
["rewind-scratch.txt", "opencode-multi-a.txt", "opencode-multi-b.txt"].includes(name),
)
.sort();
}
async function expectCreatedFiles(
session: OpenCodeRewindSession,
expectedNames: string[],
): Promise<void> {
await expect(createdFilesAtCwd(session)).resolves.toEqual([...expectedNames].sort());
}
async function closeOpenCodeRewindSession(session: OpenCodeRewindSession): Promise<void> {
closeRewindSession(session);
}
function editPrompt(input: {
fileName: string;
promptToken: string;
content: string;
doneToken: string;
}): string {
return [
`PASEO_OPENCODE_REWIND_PROMPT_${input.promptToken}.`,
`Use the edit or write tool, not shell commands, to make ${input.fileName} contain exactly:`,
"```",
input.content.trimEnd(),
"```",
`When the file is saved, reply exactly: ${input.doneToken}`,
].join("\n");
}
async function askOpenCodeToEditFile(
harness: OpenCodeRewindHarness,
session: OpenCodeRewindSession,
input: {
promptToken: string;
content: string;
doneToken: string;
},
): Promise<void> {
await harness.client.sendMessage(
session.agentId,
editPrompt({
fileName: path.basename(session.scratchPath),
promptToken: input.promptToken,
content: input.content,
doneToken: input.doneToken,
}),
);
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
}
async function rewindOpenCode(
harness: OpenCodeRewindHarness,
session: OpenCodeRewindSession,
messageId: string,
): Promise<void> {
await harness.client.rewindAgent(session.agentId, messageId, "both");
}
describe("daemon E2E (real opencode) - rewind", () => {
let harness: OpenCodeRewindHarness;
beforeAll(async () => {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { opencode: new OpenCodeAgentClient(logger) },
logger,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.70",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-rewind-real" } });
harness = { client, daemon };
}, 30_000);
afterAll(async () => {
await harness?.client.close().catch(() => undefined);
await harness?.daemon.close().catch(() => undefined);
});
test("rewinds conversation and files after a single real OpenCode edit turn", async () => {
const session = await launchOpenCodeRewindSession(harness, "opencode-rewind-single-edit-real");
try {
await askOpenCodeToEditFile(harness, session, {
promptToken: "SINGLE",
content: "BASE\nOPENCODE_SINGLE_MARKER\n",
doneToken: "OPENCODE_SINGLE_DONE",
});
const timeline = await fetchTimelineItems(harness.client, session.agentId);
expectUserMessageCount(timeline, 1);
const messageId = userMessageIdForToken(timeline, "OPENCODE_REWIND_PROMPT_SINGLE");
const sessionIdBefore = await runtimeSessionId(harness, session);
expectOpenCodeSessionId(sessionIdBefore);
const statusTransitions = await recordAgentStatusTransitions(harness, session);
try {
await rewindOpenCode(harness, session, messageId);
} finally {
statusTransitions.stop();
}
const sessionIdAfter = await runtimeSessionId(harness, session);
const fileText = await readScratchFile(session);
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
expect(sessionIdAfter).toBe(sessionIdBefore);
await expectAgentIdle(harness, session);
expectNoLoadingRegression(statusTransitions.records);
expect(fileText).toBe("BASE\n");
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
} finally {
await closeOpenCodeRewindSession(session);
}
}, 420_000);
test("records exactly one plain-text user message for a real OpenCode turn", async () => {
const session = await launchOpenCodeRewindSession(
harness,
"opencode-plain-text-user-message-real",
);
const prompt = "PASEO_OPENCODE_PLAIN_TEXT_DUP_CHECK. Reply exactly: OPENCODE_PLAIN_TEXT_DONE";
try {
await harness.client.sendMessage(session.agentId, prompt);
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
const timeline = await fetchTimelineItems(harness.client, session.agentId);
const userMessages = roleItems(timeline, "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0]?.text).toBe(prompt);
} finally {
await closeOpenCodeRewindSession(session);
}
}, 420_000);
test("rewinds a real OpenCode read-only turn without changing files", async () => {
const session = await launchOpenCodeRewindSession(harness, "opencode-rewind-read-only-real");
try {
await harness.client.sendMessage(
session.agentId,
[
"PASEO_OPENCODE_REWIND_PROMPT_READ_ONLY.",
`Inspect ${path.basename(session.scratchPath)} without editing files.`,
"Reply exactly: OPENCODE_READ_ONLY_DONE",
].join(" "),
);
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
const timeline = await fetchTimelineItems(harness.client, session.agentId);
expectUserMessageCount(timeline, 1);
const messageId = userMessageIdForToken(timeline, "OPENCODE_REWIND_PROMPT_READ_ONLY");
const sessionIdBefore = await runtimeSessionId(harness, session);
expectOpenCodeSessionId(sessionIdBefore);
const statusTransitions = await recordAgentStatusTransitions(harness, session);
try {
await rewindOpenCode(harness, session, messageId);
} finally {
statusTransitions.stop();
}
const sessionIdAfter = await runtimeSessionId(harness, session);
const fileText = await readScratchFile(session);
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
expect(sessionIdAfter).toBe(sessionIdBefore);
await expectAgentIdle(harness, session);
expectNoLoadingRegression(statusTransitions.records);
expect(fileText).toBe("BASE\n");
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
} finally {
await closeOpenCodeRewindSession(session);
}
}, 420_000);
test("rewinds every file from a single real OpenCode multi-tool edit turn", async () => {
const session = await launchOpenCodeRewindSession(harness, "opencode-rewind-multi-edit-real");
const firstPath = path.join(session.cwd, "opencode-multi-a.txt");
const secondPath = path.join(session.cwd, "opencode-multi-b.txt");
try {
await harness.client.sendMessage(
session.agentId,
[
"PASEO_OPENCODE_REWIND_PROMPT_MULTI_EDIT.",
"Create opencode-multi-a.txt with exactly OPENCODE_MULTI_A.",
"Create opencode-multi-b.txt with exactly OPENCODE_MULTI_B.",
"Do not use shell commands.",
"Reply exactly: OPENCODE_MULTI_EDIT_DONE",
].join("\n"),
);
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
await expect(fileExists(firstPath)).resolves.toBe(true);
await expect(fileExists(secondPath)).resolves.toBe(true);
const timeline = await fetchTimelineItems(harness.client, session.agentId);
expectUserMessageCount(timeline, 1);
const messageId = userMessageIdForToken(timeline, "OPENCODE_REWIND_PROMPT_MULTI_EDIT");
const sessionIdBefore = await runtimeSessionId(harness, session);
expectOpenCodeSessionId(sessionIdBefore);
const statusTransitions = await recordAgentStatusTransitions(harness, session);
try {
await rewindOpenCode(harness, session, messageId);
} finally {
statusTransitions.stop();
}
const sessionIdAfter = await runtimeSessionId(harness, session);
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
expect(sessionIdAfter).toBe(sessionIdBefore);
await expectAgentIdle(harness, session);
expectNoLoadingRegression(statusTransitions.records);
await expect(fileExists(firstPath)).resolves.toBe(false);
await expect(fileExists(secondPath)).resolves.toBe(false);
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
} finally {
await closeOpenCodeRewindSession(session);
}
}, 420_000);
test("rewinds conversation and files together against a real OpenCode session", async () => {
const session = await launchOpenCodeRewindSession(harness, "opencode-rewind-both-real");
try {
await askOpenCodeToEditFile(harness, session, {
promptToken: "BOTH_FIRST",
content: "BASE\nOPENCODE_BOTH_FIRST_MARKER\n",
doneToken: "OPENCODE_BOTH_FIRST_DONE",
});
const fileTextAfterFirstTurn = await readScratchFile(session);
const firstTimeline = await fetchTimelineItems(harness.client, session.agentId);
expectUserMessageCount(firstTimeline, 1);
await askOpenCodeToEditFile(harness, session, {
promptToken: "BOTH_SECOND",
content: "BASE\nOPENCODE_BOTH_FIRST_MARKER\nOPENCODE_BOTH_SECOND_MARKER\n",
doneToken: "OPENCODE_BOTH_SECOND_DONE",
});
const secondTimeline = await fetchTimelineItems(harness.client, session.agentId);
expectUserMessageCount(secondTimeline, 2);
const secondMessageId = userMessageIdForToken(secondTimeline, "BOTH_SECOND");
const sessionIdBefore = await runtimeSessionId(harness, session);
expectOpenCodeSessionId(sessionIdBefore);
const statusTransitions = await recordAgentStatusTransitions(harness, session);
try {
await rewindOpenCode(harness, session, secondMessageId);
} finally {
statusTransitions.stop();
}
const sessionIdAfter = await runtimeSessionId(harness, session);
const fileText = await readScratchFile(session);
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
expect(sessionIdAfter).toBe(sessionIdBefore);
await expectAgentIdle(harness, session);
expectNoLoadingRegression(statusTransitions.records);
expect(fileText).toBe(fileTextAfterFirstTurn);
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
expectTimeline(rewoundTimeline, {
userTexts: [
editPrompt({
fileName: path.basename(session.scratchPath),
promptToken: "BOTH_FIRST",
content: "BASE\nOPENCODE_BOTH_FIRST_MARKER\n",
doneToken: "OPENCODE_BOTH_FIRST_DONE",
}),
],
assistantCount: 1,
});
} finally {
await closeOpenCodeRewindSession(session);
}
}, 420_000);
});
@@ -0,0 +1,195 @@
import { readdir } from "node:fs/promises";
import pino from "pino";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import {
closeRewindSession,
fetchTimelineItems,
tmpRewindCwd,
userMessageIdForToken,
} from "./test-utils/rewind-helpers.js";
interface PiRewindHarness {
client: DaemonClient;
daemon: TestPaseoDaemon;
}
interface PiRewindSession {
agentId: string;
cwd: string;
}
const TURN_TIMEOUT_MS = 240_000;
const PI_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
async function launchPiRewindSession(
harness: PiRewindHarness,
title: string,
): Promise<PiRewindSession> {
const cwd = tmpRewindCwd("daemon-real-pi-rewind-");
const agent = await harness.client.createAgent({
cwd,
title,
provider: "pi",
model: PI_REAL_TEST_MODEL,
thinkingOptionId: "medium",
});
return { agentId: agent.id, cwd };
}
async function closePiRewindSession(session: PiRewindSession): Promise<void> {
closeRewindSession(session);
}
async function runtimeSessionId(
harness: PiRewindHarness,
session: PiRewindSession,
): Promise<string> {
const snapshot = await harness.client.fetchAgent(session.agentId);
const sessionId =
snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId;
if (!sessionId) {
throw new Error(`Agent ${session.agentId} does not have a visible Pi session id`);
}
return sessionId;
}
function expectPiSessionId(value: string): void {
expect(value.length).toBeGreaterThan(0);
}
function piPrompt(input: { promptToken: string; doneToken: string }): string {
return [
`PASEO_PI_REWIND_PROMPT_${input.promptToken}.`,
"Remember this marker for the conversation.",
`Reply exactly: ${input.doneToken}`,
].join(" ");
}
function roleItems(items: AgentTimelineItem[], role: "user_message" | "assistant_message") {
return items.filter((item) => item.type === role);
}
function expectTimeline(
items: AgentTimelineItem[],
input: { userTexts: string[]; assistantCount: number },
): void {
const userMessages = roleItems(items, "user_message");
const assistantMessages = roleItems(items, "assistant_message");
expect(userMessages).toHaveLength(input.userTexts.length);
expect(userMessages.map((message) => message.text)).toEqual(input.userTexts);
expect(assistantMessages).toHaveLength(input.assistantCount);
}
async function expectNoCreatedFiles(session: PiRewindSession): Promise<void> {
await expect(readdir(session.cwd)).resolves.toEqual([]);
}
async function askPi(
harness: PiRewindHarness,
session: PiRewindSession,
input: { promptToken: string; doneToken: string },
): Promise<void> {
await harness.client.sendMessage(session.agentId, piPrompt(input));
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
}
describe("daemon E2E (real pi) - rewind", () => {
let harness: PiRewindHarness;
beforeAll(async () => {
const logger = pino({ level: "silent" });
const daemon = await createTestPaseoDaemon({
agentClients: { pi: new PiRpcAgentClient({ logger }) },
logger,
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.70",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: "pi-rewind-real" } });
harness = { client, daemon };
}, 30_000);
afterAll(async () => {
await harness?.client.close().catch(() => undefined);
await harness?.daemon.close().catch(() => undefined);
});
test("rewinds a real Pi conversation after a single turn and keeps the session usable", async () => {
const session = await launchPiRewindSession(harness, "pi-rewind-single-conversation-real");
try {
await askPi(harness, session, {
promptToken: "SINGLE",
doneToken: "PI_SINGLE_DONE",
});
const firstTimeline = await fetchTimelineItems(harness.client, session.agentId);
const firstMessageId = userMessageIdForToken(firstTimeline, "PI_REWIND_PROMPT_SINGLE");
const sessionIdBefore = await runtimeSessionId(harness, session);
expectPiSessionId(sessionIdBefore);
await harness.client.rewindAgent(session.agentId, firstMessageId, "conversation");
const sessionIdAfter = await runtimeSessionId(harness, session);
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
expect(sessionIdAfter).toBe(sessionIdBefore);
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
await expectNoCreatedFiles(session);
await askPi(harness, session, {
promptToken: "AFTER_SINGLE",
doneToken: "PI_AFTER_SINGLE_DONE",
});
const nextTimeline = await fetchTimelineItems(harness.client, session.agentId);
expectTimeline(nextTimeline, {
userTexts: [piPrompt({ promptToken: "AFTER_SINGLE", doneToken: "PI_AFTER_SINGLE_DONE" })],
assistantCount: 1,
});
await expectNoCreatedFiles(session);
} finally {
await closePiRewindSession(session);
}
}, 420_000);
test("rewinds a real Pi conversation to an earlier user message", async () => {
const session = await launchPiRewindSession(harness, "pi-rewind-conversation-real");
try {
await askPi(harness, session, {
promptToken: "FIRST",
doneToken: "PI_FIRST_DONE",
});
const firstTimeline = await fetchTimelineItems(harness.client, session.agentId);
const firstMessageId = userMessageIdForToken(firstTimeline, "PI_REWIND_PROMPT_FIRST");
await askPi(harness, session, {
promptToken: "SECOND",
doneToken: "PI_SECOND_DONE",
});
const sessionIdBefore = await runtimeSessionId(harness, session);
expectPiSessionId(sessionIdBefore);
await harness.client.rewindAgent(session.agentId, firstMessageId, "conversation");
const sessionIdAfter = await runtimeSessionId(harness, session);
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
expect(sessionIdAfter).toBe(sessionIdBefore);
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
await expectNoCreatedFiles(session);
} finally {
await closePiRewindSession(session);
}
}, 420_000);
});
@@ -0,0 +1,111 @@
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { access, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { AgentTimelineItem } from "../../agent/agent-sdk-types.js";
import type { DaemonClient } from "../../test-utils/daemon-client.js";
export interface RewindSessionBase {
agentId: string;
cwd: string;
scratchPath?: string;
}
export function tmpRewindCwd(prefix: string, options?: { realpath?: boolean }): string {
const dir = mkdtempSync(path.join(tmpdir(), prefix));
return options?.realpath ? realpathSync(dir) : dir;
}
export function closeRewindSession(session: RewindSessionBase): void {
rmSync(session.cwd, { recursive: true, force: true });
}
export function timelineItems(
timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>,
): AgentTimelineItem[] {
return timeline.entries.map((entry) => entry.item);
}
export function textByRole(
items: AgentTimelineItem[],
role: "user_message" | "assistant_message",
): string {
return items
.filter((item) => item.type === role)
.map((item) => item.text)
.join("\n");
}
export function userMessageIdForToken(items: AgentTimelineItem[], token: string): string {
const item = items.find(
(candidate) => candidate.type === "user_message" && candidate.text.includes(token),
);
if (!item?.messageId) {
throw new Error(`Timeline did not contain a user message id for ${token}`);
}
return item.messageId;
}
export async function fetchTimelineItems(
client: DaemonClient,
agentId: string,
): Promise<AgentTimelineItem[]> {
const timeline = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 0,
projection: "canonical",
});
return timelineItems(timeline);
}
export async function readScratchFile(session: { scratchPath: string }): Promise<string> {
return await readFile(session.scratchPath, "utf8");
}
export async function fileExists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
export async function waitForTimelineItems(
client: DaemonClient,
agentId: string,
predicate: (items: AgentTimelineItem[]) => boolean,
): Promise<AgentTimelineItem[]> {
const existingItems = await fetchTimelineItems(client, agentId);
if (predicate(existingItems)) {
return existingItems;
}
return await new Promise<AgentTimelineItem[]>((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out waiting for timeline items for ${agentId}`));
}, 60_000);
const unsubscribe = client.on("agent_update", (message) => {
if (message.payload.kind !== "upsert" || message.payload.agent.id !== agentId) {
return;
}
void (async () => {
try {
const items = await fetchTimelineItems(client, agentId);
if (!predicate(items)) {
return;
}
clearTimeout(timeout);
unsubscribe();
resolve(items);
} catch (error) {
clearTimeout(timeout);
unsubscribe();
reject(error);
}
})();
});
});
}
@@ -0,0 +1,170 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import type {
AgentClient,
AgentProvider,
AgentSessionConfig,
AgentStreamEvent,
} from "../agent/agent-sdk-types.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
import { fetchTimelineItems } from "./test-utils/rewind-helpers.js";
type ContractProvider = Extract<AgentProvider, "claude" | "codex" | "opencode" | "pi">;
interface ProviderContractCase {
provider: ContractProvider;
title: string;
timeoutMs: number;
createClient: (logger: pino.Logger) => AgentClient;
createConfig: () => AgentSessionConfig;
}
const PI_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
const CONTRACT_CASES: ProviderContractCase[] = [
{
provider: "claude",
title: "Claude",
timeoutMs: 180_000,
createClient: (logger) => new ClaudeAgentClient({ logger }),
createConfig: () => getFullAccessConfig("claude"),
},
{
provider: "codex",
title: "Codex",
timeoutMs: 180_000,
createClient: (logger) => new CodexAppServerAgentClient(logger),
createConfig: () => getFullAccessConfig("codex"),
},
{
provider: "opencode",
title: "OpenCode",
timeoutMs: 180_000,
createClient: (logger) => new OpenCodeAgentClient(logger),
createConfig: () => getFullAccessConfig("opencode"),
},
{
provider: "pi",
title: "Pi",
timeoutMs: 240_000,
createClient: (logger) => new PiRpcAgentClient({ logger }),
createConfig: () => ({
provider: "pi",
model: PI_REAL_TEST_MODEL,
thinkingOptionId: "medium",
}),
},
];
function tmpCwd(provider: ContractProvider): string {
return mkdtempSync(path.join(tmpdir(), `daemon-real-${provider}-user-message-contract-`));
}
function collectUserMessageEvents(client: DaemonClient, agentId: string): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
client.on("agent_stream", (message) => {
if (message.type !== "agent_stream" || message.payload.agentId !== agentId) {
return;
}
if (message.payload.event.type !== "timeline") {
return;
}
if (message.payload.event.item.type !== "user_message") {
return;
}
events.push(message.payload.event);
});
return events;
}
describe.each(CONTRACT_CASES)("daemon E2E (real $provider) - user_message contract", (entry) => {
let canRun = false;
let daemon: TestPaseoDaemon | null = null;
let client: DaemonClient | null = null;
let cwd: string | null = null;
beforeAll(async () => {
canRun = await isProviderAvailable(entry.provider);
});
beforeEach(async (context) => {
if (!canRun) {
context.skip();
return;
}
cwd = tmpCwd(entry.provider);
const logger = pino({ level: "silent" });
daemon = await createTestPaseoDaemon({
agentClients: { [entry.provider]: entry.createClient(logger) },
logger,
});
client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.80",
});
await client.connect();
await client.fetchAgents({ subscribe: { subscriptionId: `${entry.provider}-user-contract` } });
}, 30_000);
afterEach(async () => {
await client?.close().catch(() => undefined);
await daemon?.close().catch(() => undefined);
if (cwd) {
rmSync(cwd, { recursive: true, force: true });
}
client = null;
daemon = null;
cwd = null;
});
test(
"submit one prompt emits exactly one provider user_message",
async () => {
if (!client || !cwd) {
throw new Error(`${entry.title} user-message contract test was not initialized`);
}
const prompt = `PASEO_USER_MESSAGE_CONTRACT_${entry.provider.toUpperCase()}. Reply exactly: OK.`;
const agent = await client.createAgent({
cwd,
title: `${entry.provider}-user-message-contract`,
...entry.createConfig(),
});
const liveUserEvents = collectUserMessageEvents(client, agent.id);
await client.sendMessage(agent.id, prompt);
const finish = await client.waitForFinish(agent.id, entry.timeoutMs);
expect(finish.status).toBe("idle");
expect(finish.final?.lastError).toBeUndefined();
const liveUserMessages = liveUserEvents.filter(
(event) => event.type === "timeline" && event.item.type === "user_message",
);
expect(liveUserMessages).toHaveLength(1);
const [liveUserMessage] = liveUserMessages;
expect(liveUserMessage?.type).toBe("timeline");
expect(liveUserMessage?.item.type).toBe("user_message");
expect(liveUserMessage?.item.text).toBe(prompt);
expect(liveUserMessage?.item.messageId).toEqual(expect.any(String));
expect(liveUserMessage?.item.messageId).not.toBe("");
const timeline = await fetchTimelineItems(client, agent.id);
const canonicalUserMessages = timeline.filter((item) => item.type === "user_message");
expect(canonicalUserMessages).toHaveLength(1);
expect(canonicalUserMessages[0]?.text).toBe(prompt);
expect(canonicalUserMessages[0]?.messageId).toBe(liveUserMessage?.item.messageId);
},
entry.timeoutMs + 60_000,
);
});
+38 -1
View File
@@ -1775,6 +1775,7 @@ export class Session {
private async dispatchInboundMessage(msg: SessionInboundMessage): Promise<void> {
const promise =
this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
@@ -1836,6 +1837,15 @@ export class Session {
}
}
private dispatchAgentRewindMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "agent.rewind.request":
return this.handleAgentRewindRequest(msg);
default:
return undefined;
}
}
private async handleDictationStreamStart(
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
): Promise<void> {
@@ -3522,6 +3532,33 @@ export class Session {
}
}
private async handleAgentRewindRequest(
msg: Extract<SessionInboundMessage, { type: "agent.rewind.request" }>,
): Promise<void> {
try {
await this.agentManager.rewind(msg.agentId, msg.messageId, msg.mode);
this.emit({
type: "agent.rewind.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
ok: true,
error: null,
},
});
} catch (error) {
this.emit({
type: "agent.rewind.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
ok: false,
error: error instanceof Error ? error.message : "Failed to rewind agent",
},
});
}
}
private async buildAgentSessionConfig(
config: AgentSessionConfig,
gitOptions?: GitSetupOptions,
@@ -8794,7 +8831,7 @@ export class Session {
agentId,
prompt: formatSystemNotificationPrompt(text),
unarchive: false,
recordUserMessage: false,
userMessageVisible: false,
logger: this.sessionLogger,
});
},
@@ -31,6 +31,9 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsMcpServers: false,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
};
const TEST_FEATURE_ID = "test_feature";
@@ -1083,6 +1083,8 @@ export class VoiceAssistantWebSocketServer {
daemonStatusRpc: true,
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": true,
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
rewind: true,
},
};
}
@@ -143,4 +143,37 @@ describe("agent feature schemas", () => {
expect(parsed.features).toHaveLength(1);
expect(parsed.features?.[0]?.id).toBe("fast_mode");
});
it("defaults missing rewind capabilities to false", () => {
const parsed = AgentSnapshotPayloadSchema.parse({
id: "agent-123",
provider: "codex",
cwd: "/tmp/project",
model: "gpt-5",
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-03T12:00:00.000Z",
updatedAt: "2026-04-03T12:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
});
expect(parsed.capabilities.supportsRewindConversation).toBe(false);
expect(parsed.capabilities.supportsRewindFiles).toBe(false);
expect(parsed.capabilities.supportsRewindBoth).toBe(false);
});
});
+31
View File
@@ -232,6 +232,12 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
supportsMcpServers: z.boolean(),
supportsReasoningStream: z.boolean(),
supportsToolInvocations: z.boolean(),
// COMPAT(rewind): added in v0.1.X, drop when floor >= v0.1.X.
supportsRewindConversation: z.boolean().optional().default(false),
// COMPAT(rewind): added in v0.1.X, drop when floor >= v0.1.X.
supportsRewindFiles: z.boolean().optional().default(false),
// COMPAT(rewind): added in v0.1.X, drop when floor >= v0.1.X.
supportsRewindBoth: z.boolean().optional().default(false),
});
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
@@ -1246,6 +1252,26 @@ export const SetAgentFeatureResponseMessageSchema = z.object({
payload: AgentActionResponsePayloadSchema,
});
export const AgentRewindModeSchema = z.enum(["conversation", "files", "both"]);
export const AgentRewindRequestMessageSchema = z.object({
type: z.literal("agent.rewind.request"),
agentId: z.string(),
messageId: z.string(),
mode: AgentRewindModeSchema,
requestId: z.string(),
});
export const AgentRewindResponseMessageSchema = z.object({
type: z.literal("agent.rewind.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
ok: z.boolean(),
error: z.string().nullable(),
}),
});
export const UpdateAgentResponseMessageSchema = z.object({
type: z.literal("update_agent_response"),
payload: AgentActionResponsePayloadSchema,
@@ -1854,6 +1880,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema,
SetAgentFeatureRequestMessageSchema,
AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema,
CheckoutStatusRequestSchema,
SubscribeCheckoutDiffRequestSchema,
@@ -2095,6 +2122,8 @@ export const ServerInfoStatusPayloadSchema = z
daemonStatusRpc: z.boolean().optional(),
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
"terminal-restore-modes": z.boolean().optional(),
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
rewind: z.boolean().optional(),
})
.optional(),
})
@@ -3624,6 +3653,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema,
SetAgentFeatureResponseMessageSchema,
AgentRewindResponseMessageSchema,
UpdateAgentResponseMessageSchema,
ProjectRenameResponseSchema,
WaitForFinishResponseMessageSchema,
@@ -3763,6 +3793,7 @@ export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMes
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
+266 -66
View File
@@ -1,9 +1,10 @@
import pino from "pino";
import { z } from "zod";
import { describe, expect, test, vi } from "vitest";
import { describe, expect, test } from "vitest";
import { CLIENT_CAPS } from "./client-capabilities.js";
import {
AgentSnapshotPayloadSchema,
AgentTimelineItemPayloadSchema,
FetchAgentTimelineResponseMessageSchema,
SessionInboundMessageSchema,
@@ -58,8 +59,20 @@ const LegacySubAgentToolCallSchema = z.object({
}),
});
const LegacyAgentCapabilityFlagsSchema = z.object({
supportsStreaming: z.boolean(),
supportsSessionPersistence: z.boolean(),
supportsDynamicModes: z.boolean(),
supportsMcpServers: z.boolean(),
supportsReasoningStream: z.boolean(),
supportsToolInvocations: z.boolean(),
});
const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
capabilities: LegacyAgentCapabilityFlagsSchema,
});
interface SessionInternals {
buildAgentPayload: (snapshot: unknown) => Promise<null>;
handleFetchAgentTimelineRequest: (
message: Extract<
z.infer<typeof SessionInboundMessageSchema>,
@@ -68,12 +81,140 @@ interface SessionInternals {
) => Promise<void>;
}
class InMemoryAgentManager {
constructor(private readonly rows: AgentTimelineRow[]) {}
getAgent() {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
model: null,
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: new Date("2026-05-02T00:00:00.000Z"),
updatedAt: new Date("2026-05-02T00:00:00.000Z"),
lastUserMessageAt: null,
lifecycle: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: false,
supportsRewindFiles: false,
supportsRewindBoth: false,
},
config: { provider: "codex", cwd: "/tmp/project" },
currentModeId: null,
availableModes: [],
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
inFlightPermissionResponses: new Set(),
pendingReplacement: false,
persistence: null,
historyPrimed: true,
lastUsage: undefined,
lastError: undefined,
attention: { requiresAttention: false, attentionReason: null, attentionTimestamp: null },
foregroundTurnWaiters: new Set(),
finalizedForegroundTurnIds: new Set(),
unsubscribeSession: null,
session: null,
activeForegroundTurnId: null,
labels: {},
};
}
fetchTimeline() {
return {
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 3, nextSeq: 4 },
rows: this.rows,
hasOlder: false,
hasNewer: false,
};
}
listAgents() {
return [];
}
subscribe() {
return () => {};
}
}
class EmptyAgentStorage {
async list() {
return [];
}
async get() {
return null;
}
}
class EmptyProjectRegistry {
async list() {
return [];
}
async get() {
return null;
}
async upsert() {}
async archive() {}
async remove() {}
async initialize() {}
async existsOnDisk() {
return false;
}
}
class EmptyWorkspaceRegistry {
get() {
return null;
}
list() {
return [];
}
}
class EmptyDaemonConfigStore {
get() {
return {
mcp: { injectIntoAgents: false },
providers: {},
};
}
onChange() {
return () => {};
}
}
class InMemoryWorktreeWorkflow {
readonly capturedInputs: unknown[] = [];
async create(input: unknown) {
this.capturedInputs.push(input);
return {} as never;
}
}
function createSessionForWireCompatTest(options?: {
clientCapabilities?: Record<string, unknown> | null;
messages?: SessionOutboundMessage[];
}): Session {
const messages = options?.messages ?? [];
const snapshot = { id: "agent-1", provider: "codex" };
const rows: AgentTimelineRow[] = [
{
seq: 1,
@@ -100,74 +241,62 @@ function createSessionForWireCompatTest(options?: {
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
pushTokenStore: {} as SessionOptions["pushTokenStore"],
paseoHome: "/tmp/paseo-home",
agentManager: {
getAgent: vi.fn(() => snapshot),
fetchTimeline: vi.fn(() => ({
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 3, nextSeq: 4 },
rows,
hasOlder: false,
hasNewer: false,
})),
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
} as unknown as SessionOptions["agentManager"],
agentStorage: {
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
} as unknown as SessionOptions["agentStorage"],
projectRegistry: {
list: vi.fn().mockResolvedValue([]),
get: vi.fn(),
upsert: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
initialize: vi.fn(),
existsOnDisk: vi.fn(),
} as unknown as SessionOptions["projectRegistry"],
workspaceRegistry: {
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
} as unknown as SessionOptions["workspaceRegistry"],
agentManager: new InMemoryAgentManager(rows) as unknown as SessionOptions["agentManager"],
agentStorage: new EmptyAgentStorage() as unknown as SessionOptions["agentStorage"],
projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"],
workspaceRegistry:
new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"],
chatService: {} as SessionOptions["chatService"],
scheduleService: {} as SessionOptions["scheduleService"],
loopService: {} as SessionOptions["loopService"],
checkoutDiffManager: {
scheduleRefreshForCwd: vi.fn(),
scheduleRefreshForCwd() {},
} as unknown as SessionOptions["checkoutDiffManager"],
github: {
invalidate: vi.fn(),
searchIssuesAndPrs: vi.fn(),
createPullRequest: vi.fn(),
invalidate() {},
async searchIssuesAndPrs() {
return [];
},
async createPullRequest() {
return null;
},
} as unknown as SessionOptions["github"],
workspaceGitService: {
getCheckoutDiff: vi.fn(),
getSnapshot: vi.fn(),
suggestBranchesForCwd: vi.fn(),
listStashes: vi.fn(),
peekSnapshot: vi.fn(),
validateBranchRef: vi.fn(),
hasLocalBranch: vi.fn(),
resolveRepoRemoteUrl: vi.fn(),
getWorkspaceGitMetadata: vi.fn(),
async getCheckoutDiff() {
return null;
},
async getSnapshot() {
return null;
},
async suggestBranchesForCwd() {
return [];
},
async listStashes() {
return [];
},
peekSnapshot() {
return null;
},
async validateBranchRef() {
return { ok: false, error: "not found" };
},
async hasLocalBranch() {
return false;
},
async resolveRepoRemoteUrl() {
return null;
},
async getWorkspaceGitMetadata() {
return null;
},
} as unknown as SessionOptions["workspaceGitService"],
daemonConfigStore: {
get: vi.fn(() => ({
mcp: { injectIntoAgents: false },
providers: {},
})),
onChange: vi.fn(() => () => {}),
} as unknown as SessionOptions["daemonConfigStore"],
daemonConfigStore:
new EmptyDaemonConfigStore() as unknown as SessionOptions["daemonConfigStore"],
stt: null,
tts: null,
terminalManager: null,
});
const internals = session as unknown as SessionInternals;
internals.buildAgentPayload = vi.fn(async () => null);
return session;
}
@@ -256,8 +385,82 @@ describe("wire compatibility", () => {
expect(parsed.detail.actions).toEqual([]);
});
test("old clients parse agent snapshots with rewind capabilities", () => {
const parsed = LegacyAgentSnapshotPayloadSchema.parse({
id: "agent-1",
provider: "claude",
cwd: "/tmp/project",
model: null,
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-05-23T00:00:00.000Z",
updatedAt: "2026-05-23T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
supportsRewindConversation: true,
supportsRewindFiles: true,
supportsRewindBoth: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
});
expect(parsed.capabilities).toEqual({
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
});
});
test("new clients parse agent snapshots without rewind capabilities", () => {
const parsed = AgentSnapshotPayloadSchema.parse({
id: "agent-1",
provider: "claude",
cwd: "/tmp/project",
model: null,
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-05-23T00:00:00.000Z",
updatedAt: "2026-05-23T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
});
expect(parsed.capabilities.supportsRewindConversation).toBe(false);
expect(parsed.capabilities.supportsRewindFiles).toBe(false);
expect(parsed.capabilities.supportsRewindBoth).toBe(false);
});
test("legacy worktree request shape normalizes to the same internal input as the new shape", async () => {
const captured: unknown[] = [];
const workflow = new InMemoryWorktreeWorkflow();
const dependencies = {
paseoHome: "/tmp/paseo-home",
@@ -275,12 +478,9 @@ describe("wire compatibility", () => {
activityAt: null,
scripts: [],
}) as never,
emit: vi.fn(),
emit() {},
sessionLogger: pino({ level: "silent" }),
createPaseoWorktreeWorkflow: async (input: unknown) => {
captured.push(input);
return {} as never;
},
createPaseoWorktreeWorkflow: workflow.create.bind(workflow),
};
const legacyRequest = SessionInboundMessageSchema.parse({
@@ -329,9 +529,9 @@ describe("wire compatibility", () => {
await handleCreatePaseoWorktreeRequest(dependencies, legacyRequest);
await handleCreatePaseoWorktreeRequest(dependencies, newRequest);
expect(captured).toHaveLength(2);
expect(captured[0]).toEqual(captured[1]);
expect(captured[0]).toEqual({
expect(workflow.capturedInputs).toHaveLength(2);
expect(workflow.capturedInputs[0]).toEqual(workflow.capturedInputs[1]);
expect(workflow.capturedInputs[0]).toEqual({
cwd: "/tmp/repo",
worktreeSlug: "legacy-worktree",
firstAgentContext: {