From 132e572d6ecdbba60878d9babec441fa608d28d4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 25 May 2026 17:11:34 +0800 Subject: [PATCH] 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 --- docs/providers.md | 2 + docs/testing.md | 2 +- packages/app/e2e/global-setup.ts | 15 +- packages/app/e2e/helpers/rewind-flow.ts | 342 +++++++++ packages/app/e2e/helpers/terminal-perf.ts | 16 + .../app/e2e/rewind-flow.claude.real.spec.ts | 7 + .../app/e2e/rewind-flow.codex.real.spec.ts | 7 + .../app/e2e/rewind-flow.opencode.real.spec.ts | 8 + packages/app/e2e/rewind-flow.pi.real.spec.ts | 7 + packages/app/e2e/rewind-flow.shared.ts | 88 +++ .../app/e2e/rewind-menu.ui-contract.spec.ts | 169 +++++ .../user-message-contract.ui-contract.spec.ts | 92 +++ packages/app/package.json | 3 +- packages/app/playwright.config.ts | 6 + packages/app/src/agent-stream/view.tsx | 7 +- .../src/components/composer-actions.test.ts | 1 + .../app/src/components/composer-actions.ts | 1 + packages/app/src/components/message.tsx | 30 +- .../rewind/composer-restore.test.ts | 31 + .../components/rewind/composer-restore.tsx | 68 ++ .../app/src/components/rewind/rewind-menu.tsx | 164 ++++ .../app/src/components/rewind/rewind-mode.ts | 5 + .../rewind/use-rewind-agent-mutation.ts | 80 ++ .../rewind/use-rewind-capabilities.test.ts | 36 + .../rewind/use-rewind-capabilities.ts | 53 ++ packages/app/src/contexts/session-context.tsx | 3 + .../app/src/hooks/use-agent-input-draft.ts | 2 +- .../use-agent-screen-state-machine.test.ts | 43 ++ .../hooks/use-agent-screen-state-machine.ts | 2 + .../src/hooks/use-draft-agent-create-flow.ts | 1 + packages/app/src/panels/agent-panel.tsx | 222 ++++-- .../timeline/session-stream-reducers.test.ts | 39 + .../src/timeline/session-stream-reducers.ts | 132 ++-- packages/app/src/types/stream.test.ts | 273 ++++++- packages/app/src/types/stream.ts | 56 +- packages/server/src/client/daemon-client.ts | 35 + .../src/server/agent/agent-manager.test.ts | 506 +----------- .../server/src/server/agent/agent-manager.ts | 212 +++--- .../src/server/agent/agent-projections.ts | 3 + .../server/src/server/agent/agent-prompt.ts | 29 +- .../src/server/agent/agent-sdk-types.ts | 7 + .../agent/agent-timeline-store-types.ts | 4 - .../src/server/agent/agent-timeline-store.ts | 37 - .../src/server/agent/mcp-server.test.ts | 1 - .../server/src/server/agent/mcp-server.ts | 8 - .../agent/provider-launch-config.test.ts | 4 +- .../server/agent/provider-launch-config.ts | 1 - .../agent/provider-registry-wrap.test.ts | 197 +++++ .../src/server/agent/provider-registry.ts | 6 +- .../src/server/agent/providers/acp-agent.ts | 3 + .../agent/providers/claude/agent.test.ts | 8 +- .../server/agent/providers/claude/agent.ts | 408 +++++++--- .../agent/providers/claude/rewind.test.ts | 89 +++ .../server/agent/providers/claude/rewind.ts | 57 ++ .../claude/test-rewind-claude-sdk.ts | 36 + .../codex-app-server-agent.features.test.ts | 4 +- .../providers/codex-app-server-agent.test.ts | 132 +++- .../agent/providers/codex-app-server-agent.ts | 332 ++++++-- .../providers/codex-rollout-parsing.test.ts | 547 ------------- .../agent/providers/codex-rollout-timeline.ts | 641 ---------------- .../codex/app-server-transport.test.ts | 87 ++- .../providers/codex/app-server-transport.ts | 84 ++ .../agent/providers/codex/rewind.test.ts | 150 ++++ .../server/agent/providers/codex/rewind.ts | 87 +++ .../codex/test-utils/fake-app-server.ts | 50 ++ .../providers/codex/tool-call-mapper.test.ts | 718 ++++++++++-------- .../agent/providers/codex/tool-call-mapper.ts | 22 +- .../agent/providers/copilot-acp-agent.ts | 3 + .../agent/providers/mock-load-test-agent.ts | 63 +- .../agent/providers/opencode-agent.test.ts | 8 + .../server/agent/providers/opencode-agent.ts | 86 ++- .../agent/providers/opencode/rewind.test.ts | 69 ++ .../server/agent/providers/opencode/rewind.ts | 29 + .../server/agent/providers/pi/agent.test.ts | 277 ++++--- .../src/server/agent/providers/pi/agent.ts | 427 ++++++++++- .../agent/providers/pi/history-mapper.test.ts | 50 +- .../agent/providers/pi/history-mapper.ts | 9 +- .../src/server/agent/providers/pi/rewind.ts | 14 + .../server/agent/providers/pi/rpc-types.ts | 6 + .../src/server/agent/providers/pi/runtime.ts | 6 + .../providers/pi/session-descriptor.test.ts | 297 -------- .../agent/providers/pi/session-descriptor.ts | 372 --------- .../agent/providers/pi/test-utils/fake-pi.ts | 64 +- .../providers/provider-availability.test.ts | 4 +- .../src/server/agent/rewind/rewind.test.ts | 118 +++ .../server/src/server/agent/rewind/rewind.ts | 36 + .../agent/rewind/test-rewind-session.ts | 125 +++ .../daemon-e2e/claude-rewind.real.e2e.test.ts | 341 +++++++++ .../daemon-e2e/codex-rewind.real.e2e.test.ts | 251 ++++++ .../opencode-rewind.real.e2e.test.ts | 442 +++++++++++ .../daemon-e2e/pi-rewind.real.e2e.test.ts | 195 +++++ .../daemon-e2e/test-utils/rewind-helpers.ts | 111 +++ .../user-message-contract.real.e2e.test.ts | 170 +++++ packages/server/src/server/session.ts | 39 +- .../server/test-utils/fake-agent-client.ts | 3 + .../server/src/server/websocket-server.ts | 2 + .../src/shared/agent-feature-schemas.test.ts | 33 + packages/server/src/shared/messages.ts | 31 + .../server/src/shared/wire-compat.test.ts | 332 ++++++-- 99 files changed, 7106 insertions(+), 3425 deletions(-) create mode 100644 packages/app/e2e/helpers/rewind-flow.ts create mode 100644 packages/app/e2e/rewind-flow.claude.real.spec.ts create mode 100644 packages/app/e2e/rewind-flow.codex.real.spec.ts create mode 100644 packages/app/e2e/rewind-flow.opencode.real.spec.ts create mode 100644 packages/app/e2e/rewind-flow.pi.real.spec.ts create mode 100644 packages/app/e2e/rewind-flow.shared.ts create mode 100644 packages/app/e2e/rewind-menu.ui-contract.spec.ts create mode 100644 packages/app/e2e/user-message-contract.ui-contract.spec.ts create mode 100644 packages/app/src/components/rewind/composer-restore.test.ts create mode 100644 packages/app/src/components/rewind/composer-restore.tsx create mode 100644 packages/app/src/components/rewind/rewind-menu.tsx create mode 100644 packages/app/src/components/rewind/rewind-mode.ts create mode 100644 packages/app/src/components/rewind/use-rewind-agent-mutation.ts create mode 100644 packages/app/src/components/rewind/use-rewind-capabilities.test.ts create mode 100644 packages/app/src/components/rewind/use-rewind-capabilities.ts create mode 100644 packages/server/src/server/agent/provider-registry-wrap.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/rewind.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/rewind.ts create mode 100644 packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts delete mode 100644 packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts delete mode 100644 packages/server/src/server/agent/providers/codex-rollout-timeline.ts create mode 100644 packages/server/src/server/agent/providers/codex/rewind.test.ts create mode 100644 packages/server/src/server/agent/providers/codex/rewind.ts create mode 100644 packages/server/src/server/agent/providers/opencode/rewind.test.ts create mode 100644 packages/server/src/server/agent/providers/opencode/rewind.ts create mode 100644 packages/server/src/server/agent/providers/pi/rewind.ts delete mode 100644 packages/server/src/server/agent/providers/pi/session-descriptor.test.ts delete mode 100644 packages/server/src/server/agent/providers/pi/session-descriptor.ts create mode 100644 packages/server/src/server/agent/rewind/rewind.test.ts create mode 100644 packages/server/src/server/agent/rewind/rewind.ts create mode 100644 packages/server/src/server/agent/rewind/test-rewind-session.ts create mode 100644 packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts create mode 100644 packages/server/src/server/daemon-e2e/test-utils/rewind-helpers.ts create mode 100644 packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts diff --git a/docs/providers.md b/docs/providers.md index fd6038856..1d632deed 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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. --- diff --git a/docs/testing.md b/docs/testing.md index 9344e09bb..6a3a48c1e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 883dc86bc..5ab1a7449 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -537,13 +537,22 @@ async function awaitRelayReady( } } -async function startRelay(): Promise { +async function getAvailablePortExcluding(excludedPorts: Set): Promise { + for (;;) { + const port = await getAvailablePort(); + if (!excludedPorts.has(port)) { + return port; + } + } +} + +async function startRelay(excludedPorts: Set): Promise { 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, diff --git a/packages/app/e2e/helpers/rewind-flow.ts b/packages/app/e2e/helpers/rewind-flow.ts new file mode 100644 index 000000000..8b8ea7c3a --- /dev/null +++ b/packages/app/e2e/helpers/rewind-flow.ts @@ -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 { + 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> { + 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 { + 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 { + await handle.client.close().catch(() => undefined); +} + +export async function sendMessage(handle: AgentHandle, text: string): Promise { + 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, + expected: TranscriptMessage[], +): Promise { + 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 { + 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 { + await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true); +} + +export async function assertFileMissing(filePath: string): Promise { + await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false); +} + +export async function assertFileContains(filePath: string, text: string): Promise { + await assertFileExists(filePath); + await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text); +} + +export async function assertComposerIdle(handle: Pick): Promise { + 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 { + if (input.handle) { + await closeAgent(input.handle); + } + rmSync(input.cwd, { recursive: true, force: true }); +} + +async function fetchTimelineEpoch(handle: AgentHandle): Promise { + 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 { + await expect + .poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS }) + .not.toBe(beforeEpoch); +} diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index 676b0eb6d..ca0d91436 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -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; + 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 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); diff --git a/packages/app/e2e/rewind-flow.claude.real.spec.ts b/packages/app/e2e/rewind-flow.claude.real.spec.ts new file mode 100644 index 000000000..66f418d53 --- /dev/null +++ b/packages/app/e2e/rewind-flow.claude.real.spec.ts @@ -0,0 +1,7 @@ +import { defineRewindFlowSpec } from "./rewind-flow.shared"; + +defineRewindFlowSpec({ + provider: "claude", + rewindMode: "both", + fileReverted: true, +}); diff --git a/packages/app/e2e/rewind-flow.codex.real.spec.ts b/packages/app/e2e/rewind-flow.codex.real.spec.ts new file mode 100644 index 000000000..ae5a9a159 --- /dev/null +++ b/packages/app/e2e/rewind-flow.codex.real.spec.ts @@ -0,0 +1,7 @@ +import { defineRewindFlowSpec } from "./rewind-flow.shared"; + +defineRewindFlowSpec({ + provider: "codex", + rewindMode: "conversation", + fileReverted: false, +}); diff --git a/packages/app/e2e/rewind-flow.opencode.real.spec.ts b/packages/app/e2e/rewind-flow.opencode.real.spec.ts new file mode 100644 index 000000000..0d014736e --- /dev/null +++ b/packages/app/e2e/rewind-flow.opencode.real.spec.ts @@ -0,0 +1,8 @@ +import { defineRewindFlowSpec } from "./rewind-flow.shared"; + +defineRewindFlowSpec({ + provider: "opencode", + initialRewindMode: "both", + rewindMode: "both", + fileReverted: true, +}); diff --git a/packages/app/e2e/rewind-flow.pi.real.spec.ts b/packages/app/e2e/rewind-flow.pi.real.spec.ts new file mode 100644 index 000000000..e8757b640 --- /dev/null +++ b/packages/app/e2e/rewind-flow.pi.real.spec.ts @@ -0,0 +1,7 @@ +import { defineRewindFlowSpec } from "./rewind-flow.shared"; + +defineRewindFlowSpec({ + provider: "pi", + rewindMode: "conversation", + fileReverted: false, +}); diff --git a/packages/app/e2e/rewind-flow.shared.ts b/packages/app/e2e/rewind-flow.shared.ts new file mode 100644 index 000000000..c34e9d782 --- /dev/null +++ b/packages/app/e2e/rewind-flow.shared.ts @@ -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 }); + } + }); + }); +} diff --git a/packages/app/e2e/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/rewind-menu.ui-contract.spec.ts new file mode 100644 index 000000000..e6c74bcf8 --- /dev/null +++ b/packages/app/e2e/rewind-menu.ui-contract.spec.ts @@ -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 { + 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 { + 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(); + } + }); +}); diff --git a/packages/app/e2e/user-message-contract.ui-contract.spec.ts b/packages/app/e2e/user-message-contract.ui-contract.spec.ts new file mode 100644 index 000000000..bba6bde94 --- /dev/null +++ b/packages/app/e2e/user-message-contract.ui-contract.spec.ts @@ -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 { + 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 { + await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 }); +} + +async function expectIdleComposer(page: Page): Promise { + await expectComposerEditable(page); + await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { + timeout: 15_000, + }); +} + +async function expectNoLoadingRegressionAfterIdle(page: Page): Promise { + 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(); + } + }); +}); diff --git a/packages/app/package.json b/packages/app/package.json index 77cd97982..756b9b494 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index b03c7197e..071c7c5a0 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -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"] }, }, ], diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index bbb212406..372c66eb3 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -412,16 +412,21 @@ const AgentStreamViewComponent = forwardRef) => { return ( ); }, - [], + [agent.capabilities, agentId, client, resolvedServerId], ); const renderAssistantMessageItem = useCallback( diff --git a/packages/app/src/components/composer-actions.test.ts b/packages/app/src/components/composer-actions.test.ts index ac7749db5..3067c52cf 100644 --- a/packages/app/src/components/composer-actions.test.ts +++ b/packages/app/src/components/composer-actions.test.ts @@ -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 () => { diff --git a/packages/app/src/components/composer-actions.ts b/packages/app/src/components/composer-actions.ts index 3827b31bc..82e27fc9b 100644 --- a/packages/app/src/components/composer-actions.ts +++ b/packages/app/src/components/composer-actions.ts @@ -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 } : {}), }; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 3b96077ec..344992c0f 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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; 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 ( - + {formattedTimestamp} + {capabilities ? ( + + ) : null} { + 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); + }); +}); diff --git a/packages/app/src/components/rewind/composer-restore.tsx b/packages/app/src/components/rewind/composer-restore.tsx new file mode 100644 index 000000000..286a516ac --- /dev/null +++ b/packages/app/src/components/rewind/composer-restore.tsx @@ -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(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 ( + + {children} + + ); +} + +export function useRewindComposerRestore(): RewindComposerRestoreContextValue | null { + return useContext(RewindComposerRestoreContext); +} diff --git a/packages/app/src/components/rewind/rewind-menu.tsx b/packages/app/src/components/rewind/rewind-menu.tsx new file mode 100644 index 000000000..b28686271 --- /dev/null +++ b/packages/app/src/components/rewind/rewind-menu.tsx @@ -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; + 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(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) => , []); + const tooltipContent = useMemo( + () => ( + + Rewind to this message + + ), + [], + ); + + if (items.length === 0) { + return null; + } + + return ( + <> + + + + ))} + + + + + ); +}); + +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", + }, +})); diff --git a/packages/app/src/components/rewind/rewind-mode.ts b/packages/app/src/components/rewind/rewind-mode.ts new file mode 100644 index 000000000..e7f461ab3 --- /dev/null +++ b/packages/app/src/components/rewind/rewind-mode.ts @@ -0,0 +1,5 @@ +import type { RewindMode } from "./use-rewind-capabilities"; + +export function shouldRestoreComposerForRewindMode(mode: RewindMode): boolean { + return mode === "conversation" || mode === "both"; +} diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts new file mode 100644 index 000000000..06c645e06 --- /dev/null +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -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; + 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, + }; +} diff --git a/packages/app/src/components/rewind/use-rewind-capabilities.test.ts b/packages/app/src/components/rewind/use-rewind-capabilities.test.ts new file mode 100644 index 000000000..62899a6f3 --- /dev/null +++ b/packages/app/src/components/rewind/use-rewind-capabilities.test.ts @@ -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", + }, + ]); + }); +}); diff --git a/packages/app/src/components/rewind/use-rewind-capabilities.ts b/packages/app/src/components/rewind/use-rewind-capabilities.ts new file mode 100644 index 000000000..f00525030 --- /dev/null +++ b/packages/app/src/components/rewind/use-rewind-capabilities.ts @@ -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[0], +): RewindMenuItem[] { + return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]); +} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 30e071029..3544a54a3 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -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 diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index f8c468ae2..796c519dd 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & { commandDraftConfig: DraftCommandConfig | undefined; }; -interface AgentInputDraft { +export interface AgentInputDraft { text: string; setText: (text: string) => void; attachments: UserComposerAttachment[]; diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index d47a6bf48..f4148f49c 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -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 = { diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 3113c81ba..5a9875e2f 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -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?: { diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.ts b/packages/app/src/hooks/use-draft-agent-create-flow.ts index afc14d4d5..3286b1240 100644 --- a/packages/app/src/hooks/use-draft-agent-create-flow.ts +++ b/packages/app/src/hooks/use-draft-agent-create-flow.ts @@ -133,6 +133,7 @@ export function useDraftAgentCreateFlow({ 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 } : {}), diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 6c40eab6d..57f0409ef 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -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 ( - - - - - - - - + + ); +} - +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; + streamViewRef: React.RefObject; + 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; + onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; +}) { + const agentInputDraft = useAgentInputDraft({ + draftKey: buildDraftStoreKey({ + serverId, + agentId, + }), + }); - {viewState.tag === "ready" && - viewState.sync.status === "catching_up" && - viewState.sync.ui === "overlay" ? ( - - + return ( + + + + + + + + - ) : null} - - - + - {isArchivingCurrentAgent ? ( - - - Archiving agent... - Please wait while we archive this agent. - - ) : null} - + {showHistorySyncOverlay ? ( + + + + ) : null} + + + + + + {isArchivingCurrentAgent ? ( + + + Archiving agent... + Please wait while we archive this agent. + + ) : null} + + ); } @@ -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, diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index 019901591..13bdec08e 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -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, diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 411021a7f..e8a0ee738 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -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, }; } diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index 8882b8209..65d9e2881 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -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"], + ); + }); }); diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 105cdf1c7..96edfdbaf 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -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), diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index b0aef2e33..481d06f1a 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -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 { + 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 { const requestId = this.createRequestId(); const message = SessionInboundMessageSchema.parse({ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index be71518f2..0bad9d636 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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 { - 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 { - return true; - } - - async createSession(config: AgentSessionConfig): Promise { - // Fresh session yields history with user messages (simulates Codex resume) - return new HistoryWithUserMessagesSession(config); - } - - async resumeSession(): Promise { - 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 { - 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 { - return true; - } - async createSession(config: AgentSessionConfig): Promise { - return new HistoryWithMixedUserMessagesSession(config); - } - async resumeSession(): Promise { - 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 => - 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 => - 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 { - return true; - } - async createSession(config: AgentSessionConfig): Promise { - return new EchoUserMessageSession(config); - } - async resumeSession(): Promise { - 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 { - return true; - } - async createSession(config: AgentSessionConfig): Promise { - return new DifferentMessageIdSession(config); - } - async resumeSession(): Promise { - 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 => - 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 { - return true; - } - async createSession(config: AgentSessionConfig): Promise { - return new NoMessageIdSession(config); - } - async resumeSession(): Promise { - 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); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 00b3d5fc8..c7fb4c633 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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; } 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, -): 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 { 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 { + async hydrateTimelineFromProvider( + agentId: string, + options?: HydrateTimelineOptions, + ): Promise { const agent = this.requireSessionAgent(agentId); - await this.hydrateTimelineFromLegacyProviderHistory(agent); + await this.hydrateTimelineFromLegacyProviderHistory(agent, options); + } + + async rewind(agentId: string, messageId: string, mode: RewindMode): Promise { + 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 { @@ -2074,19 +2065,6 @@ export class AgentManager { return await this.durableTimelineStore.getLastItem(agentId); } - private async hasCommittedUserMessageFromStores( - agentId: string, - options: { messageId: string; text: string }, - ): Promise { - 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 { - if (agent.historyPrimed) { + private async hydrateTimelineFromLegacyProviderHistory( + agent: ActiveManagedAgent, + options?: HydrateTimelineOptions, + ): Promise { + if (agent.historyPrimed && !options?.force) { return; } + + if (options?.force) { + const historyEvents: Extract[] = []; + 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; - options: - | { - fromHistory?: boolean; - canonicalUserMessagesById?: ReadonlyMap; - } - | undefined; + options: { fromHistory?: boolean } | undefined; isForegroundEvent: boolean; flags: StreamEventFlags; }): Promise { - 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; eventTurnId: string | undefined; isForegroundEvent: boolean; - options: - | { - fromHistory?: boolean; - canonicalUserMessagesById?: ReadonlyMap; - } - | undefined; + options: { fromHistory?: boolean } | undefined; }): Promise { 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; - }, + options?: { fromHistory?: boolean }, ): Promise { if (options?.fromHistory) { return; diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 49d1ffe4e..67b278e97 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -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); diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index ed50b9b0e..82c85797a 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -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, }); } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index cdeee51bf..195f1d943 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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; setThinkingOption?(thinkingOptionId: string | null): Promise; setFeature?(featureId: string, value: unknown): Promise; + revertConversation?(input: { messageId: string }): Promise; + revertFiles?(input: { messageId: string }): Promise; + revertBoth?(input: { messageId: string }): Promise; /** * Out-of-band prompt handler. When non-null, the manager runs the returned * handler instead of allocating a turn. The handler emits stream events diff --git a/packages/server/src/server/agent/agent-timeline-store-types.ts b/packages/server/src/server/agent/agent-timeline-store-types.ts index 9ff325bcc..779a55691 100644 --- a/packages/server/src/server/agent/agent-timeline-store-types.ts +++ b/packages/server/src/server/agent/agent-timeline-store-types.ts @@ -56,10 +56,6 @@ export interface AgentTimelineStore { getCommittedRows(agentId: string): Promise; getLastItem(agentId: string): Promise; getLastAssistantMessage(agentId: string): Promise; - hasCommittedUserMessage( - agentId: string, - options: { messageId: string; text: string }, - ): Promise; deleteAgent(agentId: string): Promise; bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise; } diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index f6ee9c848..f374b1708 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -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; @@ -282,35 +274,6 @@ export class InMemoryAgentTimelineStore { return chunks.toReversed().join(""); } - getCanonicalUserMessagesById(agentId: string): Map { - 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) { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 1c1e18d1c..416cef14a 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -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), diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 4727c654d..1ffab6819 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -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) { diff --git a/packages/server/src/server/agent/provider-launch-config.test.ts b/packages/server/src/server/agent/provider-launch-config.test.ts index 1ee8c3985..42fc13c60 100644 --- a/packages/server/src/server/agent/provider-launch-config.test.ts +++ b/packages/server/src/server/agent/provider-launch-config.test.ts @@ -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"); }); }); diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 26eb093a2..2ca2c2e1d 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -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 { diff --git a/packages/server/src/server/agent/provider-registry-wrap.test.ts b/packages/server/src/server/agent/provider-registry-wrap.test.ts new file mode 100644 index 000000000..ae1254efa --- /dev/null +++ b/packages/server/src/server/agent/provider-registry-wrap.test.ts @@ -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 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 { + 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", + ]); + }); +}); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 6cc26ca85..5bdb5c1c5 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -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), }; } diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 583fe88b6..a4d35216d 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -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 = { diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 8f4ae15a1..ae8f575be 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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", + }, + ]); }); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 27bcabf6d..02f36805b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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): 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 | 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(); + 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 { + 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 { + const messageId = await this.resolveClaudeMessageId(input.messageId); + await revertClaudeFiles({ + query: await this.ensureQuery(), + messageId, + }); + } + + async revertBoth(input: { messageId: string }): Promise { + 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 { if (this.query && !this.queryRestartNeeded) { return this.query; @@ -2353,6 +2531,13 @@ class ClaudeAgentSession implements AgentSession { }, "Resolved Claude executable", ); + const sessionBinding: Pick = {}; + 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, + 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, 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; diff --git a/packages/server/src/server/agent/providers/claude/rewind.test.ts b/packages/server/src/server/agent/providers/claude/rewind.test.ts new file mode 100644 index 000000000..99243bb9c --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/rewind.test.ts @@ -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"); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/rewind.ts b/packages/server/src/server/agent/providers/claude/rewind.ts new file mode 100644 index 000000000..9c3e3d708 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/rewind.ts @@ -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; + setSessionId: (sessionId: string) => void; +}): Promise { + 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; +}): Promise { + 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; + setSessionId: (sessionId: string) => void; +}): Promise { + await revertClaudeFiles({ + query: input.query, + messageId: input.messageId, + resolveMessageId: input.resolveMessageId, + }); + await revertClaudeConversation(input); +} diff --git a/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts new file mode 100644 index 000000000..496d3bc20 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/test-rewind-claude-sdk.ts @@ -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 }; + }, + }; + } +} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index b4bb92400..d6583a275 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -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(), diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 8abafe00c..ffa0b66d2 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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 = {}, 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; 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"); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 8bf5364d6..13c3a8b72 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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; + forkThread?(params: CodexThreadForkParams): Promise; + rollbackThread?(params: CodexThreadRollbackParams): Promise; notify(method: string, params?: unknown): void; dispose(): Promise; } @@ -343,7 +355,7 @@ function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): un return normalized; } -function normalizeCodexOutputSchema(schema: unknown): Record { +export function normalizeCodexOutputSchema(schema: unknown): Record { 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 { +export async function findCodexMicrosoftStoreBinary(): Promise { if (process.platform !== "win32") { return null; } @@ -425,7 +437,7 @@ async function findCodexMicrosoftStoreBinary(): Promise { return null; } -async function findDefaultCodexBinary(): Promise { +export async function findDefaultCodexBinary(): Promise { return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary()); } @@ -597,7 +609,7 @@ async function listCodexCustomPrompts(): Promise { return commands.sort((a, b) => a.name.localeCompare(b.name)); } -async function listCodexSkills( +export async function listCodexSkills( cwd: string, workspaceGitService?: Pick, ): Promise { @@ -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 { + if (client.forkThread) { + return client.forkThread(params); + } + return parseCodexThreadForkResponse(await client.request("thread/fork", params)); +} + +export async function rollbackCodexThread( + client: CodexAppServerClientLike, + params: CodexThreadRollbackParams, +): Promise { + if (client.rollbackThread) { + return client.rollbackThread(params); + } + return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params)); +} + function toSandboxPolicy(type: string, networkAccess?: boolean): Record { 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, ): NodeJS.ProcessEnv { @@ -2762,7 +2852,7 @@ interface CodexSubAgentCallState { childItems: Map; } -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(); private latestUsage: AgentUsage | undefined; private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null; + private readonly userMessageTurnIndexes = new Map(); + private readonly userMessageTurnIds: string[] = []; private pendingManualCompactionStarts = 0; private compactionTriggerByItemId = new Map(); // 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 { + 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 { 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, + ): void { + this.truncateCodexUserMessageTurns(parsed.numTurns); + } + private handleContextCompactedNotification( parsed: Extract, ): 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, + ): 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 { } return "Skill"; } - -export const __codexAppServerInternals = { - buildCodexAppServerEnv, - CodexAppServerClient, - codexModelSupportsFastMode, - CodexAppServerAgentSession, - findCodexMicrosoftStoreBinary, - findDefaultCodexBinary, - formatCodexQuestionPrompts, - mapCodexQuestionRequestToToolCall, - mapCodexPatchNotificationToToolCall, - planStepsToMarkdown, - mapCodexPlanToToolCall, - listCodexSkills, - normalizeCodexOutputSchema, - normalizeCodexQuestionPrompts, - toAgentUsage, - threadItemToTimeline, -}; diff --git a/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts b/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts deleted file mode 100644 index 0c245427f..000000000 --- a/packages/server/src/server/agent/providers/codex-rollout-parsing.test.ts +++ /dev/null @@ -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!", - }); - }); -}); diff --git a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts b/packages/server/src/server/agent/providers/codex-rollout-timeline.ts deleted file mode 100644 index b4f24c301..000000000 --- a/packages/server/src/server/agent/providers/codex-rollout-timeline.ts +++ /dev/null @@ -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 { - 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; -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("")) { - return true; - } - if (lower.startsWith("")) { - 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 { - 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 { - const outputsByCallId = parsedRecords - .filter( - (record): record is Extract => - record.kind === "output", - ) - .reduce((map, record) => map.set(record.callId, record.output), new Map()); - - const commandsBySessionId = new Map(); - 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): 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 { - 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 => - record.kind === "output", - ) - .reduce((map, record) => map.set(record.callId, record.output), new Map()); - 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 { - 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 []; - } -} diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts index d85db22a7..a6c5d2a8b 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.test.ts @@ -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((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(); }); }); diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index 410fc9c2d..e99360bf3 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -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 | 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; + +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; + +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 { + return parseCodexThreadForkResponse(await this.request("thread/fork", params)); + } + + async rollbackThread(params: CodexThreadRollbackParams): Promise { + return parseCodexThreadRollbackResponse(await this.request("thread/rollback", params)); + } + notify(method: string, params?: unknown): void { if (this.disposed) { return; diff --git a/packages/server/src/server/agent/providers/codex/rewind.test.ts b/packages/server/src/server/agent/providers/codex/rewind.test.ts new file mode 100644 index 000000000..c9c751c50 --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/rewind.test.ts @@ -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 { + 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 { + this.recordedRollbacks.push(params); + return { + thread: { + id: params.threadId, + sessionId: "forked-session", + forkedFromId: "source-thread", + turns: [], + }, + }; + } + + request(): Promise { + throw new Error("FakeCodex uses typed thread methods"); + } +} + +class CodexMessageTurns implements CodexUserMessageTurnIndex { + constructor(private readonly indexesByMessageId: Map) {} + + 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([]); + }); +}); diff --git a/packages/server/src/server/agent/providers/codex/rewind.ts b/packages/server/src/server/agent/providers/codex/rewind.ts new file mode 100644 index 000000000..9f7bb3bcc --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/rewind.ts @@ -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; + rollbackThread?(params: CodexThreadRollbackParams): Promise; + request(method: string, params?: unknown, timeoutMs?: number): Promise; +} + +export interface CodexUserMessageTurnIndex { + resolve(messageId: string): number | null; + count(): number; +} + +async function forkCodexThread( + client: CodexRewindClient, + params: CodexThreadForkParams, +): Promise { + if (client.forkThread) { + return client.forkThread(params); + } + return parseCodexThreadForkResponse(await client.request("thread/fork", params)); +} + +async function rollbackCodexThread( + client: CodexRewindClient, + params: CodexThreadRollbackParams, +): Promise { + 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; +}): Promise { + 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 ` 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); +} diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 235328db4..4b85c714e 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -14,8 +14,11 @@ type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & { export interface FakeCodexAppServer { readonly child: CodexAppServerChildProcess; + readonly recordedRollbacks: JsonObject[]; assertNoErrors(): void; waitForTurnStart(): Promise; + nextResponse(): Promise; + completeTurn(params?: { threadId?: string }): void; requestCommandApproval(params: { itemId: string; threadId: string; @@ -46,6 +49,7 @@ export function createFakeCodexAppServer( handlers: Record = {}, ): FakeCodexAppServer { const child = createCodexAppServerChildProcess(); + const recordedRollbacks: JsonObject[] = []; const responseHandlers: Record = { 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((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; diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 4271f4059..bb69ee2e7 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -1,47 +1,50 @@ import { describe, expect, it } from "vitest"; -import { mapCodexRolloutToolCall, mapCodexToolCallFromThreadItem } from "./tool-call-mapper.js"; +import { mapCodexToolCallEnvelope, mapCodexToolCallFromThreadItem } from "./tool-call-mapper.js"; function expectMapped(item: T | null): T { - expect(item).toBeTruthy(); - if (!item) { - throw new Error("Expected mapped tool call"); - } - return item; + expect(item).not.toBeNull(); + return item as T; } describe("codex tool-call mapper", () => { it("maps commandExecution start into running canonical call", () => { - const item = mapCodexToolCallFromThreadItem({ - type: "commandExecution", - id: "codex-call-1", - status: "running", - command: "pwd", - cwd: "/tmp/repo", - }); + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-1", + status: "running", + command: "pwd", + cwd: "/tmp/repo", + }), + ); - expect(item).toBeTruthy(); - expect(item?.status).toBe("running"); - expect(item?.error).toBeNull(); - expect(item?.callId).toBe("codex-call-1"); - expect(item?.name).toBe("shell"); - expect(item?.detail).toEqual({ - type: "shell", - command: "pwd", - cwd: "/tmp/repo", + expect(item).toEqual({ + type: "tool_call", + callId: "codex-call-1", + name: "shell", + status: "running", + error: null, + detail: { + type: "shell", + command: "pwd", + cwd: "/tmp/repo", + }, }); }); it("unwraps shell wrapper arrays for commandExecution", () => { - const item = mapCodexToolCallFromThreadItem({ - type: "commandExecution", - id: "codex-call-wrapper-array", - status: "running", - command: ["/bin/zsh", "-lc", "echo hello"], - cwd: "/tmp/repo", - }); + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-wrapper-array", + status: "running", + command: ["/bin/zsh", "-lc", "echo hello"], + cwd: "/tmp/repo", + }), + ); - expect(item?.detail).toEqual({ + expect(item.detail).toEqual({ type: "shell", command: "echo hello", cwd: "/tmp/repo", @@ -49,15 +52,17 @@ describe("codex tool-call mapper", () => { }); it("unwraps shell wrapper strings for commandExecution", () => { - const item = mapCodexToolCallFromThreadItem({ - type: "commandExecution", - id: "codex-call-wrapper-string", - status: "running", - command: '/bin/zsh -lc "echo hello"', - cwd: "/tmp/repo", - }); + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-wrapper-string", + status: "running", + command: '/bin/zsh -lc "echo hello"', + cwd: "/tmp/repo", + }), + ); - expect(item?.detail).toEqual({ + expect(item.detail).toEqual({ type: "shell", command: "echo hello", cwd: "/tmp/repo", @@ -65,16 +70,18 @@ describe("codex tool-call mapper", () => { }); it("unwraps pwsh wrapper strings for commandExecution on Windows", () => { - const item = mapCodexToolCallFromThreadItem({ - type: "commandExecution", - id: "codex-call-wrapper-pwsh-string", - status: "running", - command: - '"C:\\Users\\example\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe" -NoLogo -NoProfile -Command "echo hello"', - cwd: "C:\\repo", - }); + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-wrapper-pwsh-string", + status: "running", + command: + '"C:\\Users\\example\\AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe" -NoLogo -NoProfile -Command "echo hello"', + cwd: "C:\\repo", + }), + ); - expect(item?.detail).toEqual({ + expect(item.detail).toEqual({ type: "shell", command: "echo hello", cwd: "C:\\repo", @@ -82,15 +89,17 @@ describe("codex tool-call mapper", () => { }); it("unwraps cmd wrapper arrays for commandExecution on Windows", () => { - const item = mapCodexToolCallFromThreadItem({ - type: "commandExecution", - id: "codex-call-wrapper-cmd-array", - status: "running", - command: ["cmd.exe", "/c", "echo hello"], - cwd: "C:\\repo", - }); + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-wrapper-cmd-array", + status: "running", + command: ["cmd.exe", "/c", "echo hello"], + cwd: "C:\\repo", + }), + ); - expect(item?.detail).toEqual({ + expect(item.detail).toEqual({ type: "shell", command: "echo hello", cwd: "C:\\repo", @@ -98,26 +107,26 @@ describe("codex tool-call mapper", () => { }); it("keeps only command output body when commandExecution output is wrapped in shell envelope", () => { - const item = mapCodexToolCallFromThreadItem({ - type: "commandExecution", - id: "codex-call-envelope-output", - status: "completed", + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "commandExecution", + id: "codex-call-envelope-output", + status: "completed", + command: "echo hello", + cwd: "/tmp/repo", + aggregatedOutput: + 'Chunk ID: e87d40\nWall time: 0.0521 seconds\nProcess exited with code 0\nOriginal token count: 192\nOutput:\n214 export type AgentPermissionRequestKind = "tool";', + exitCode: 0, + }), + ); + + expect(item.detail).toEqual({ + type: "shell", command: "echo hello", cwd: "/tmp/repo", - aggregatedOutput: - 'Chunk ID: e87d40\nWall time: 0.0521 seconds\nProcess exited with code 0\nOriginal token count: 192\nOutput:\n214 export type AgentPermissionRequestKind = "tool";', + output: '214 export type AgentPermissionRequestKind = "tool";', exitCode: 0, }); - - expect(item?.detail?.type).toBe("shell"); - if (item?.detail?.type === "shell") { - expect(item.detail.output).toBe('214 export type AgentPermissionRequestKind = "tool";'); - expect(item.detail.output).not.toContain("Chunk ID:"); - expect(item.detail.output).not.toContain("Wall time:"); - expect(item.detail.output).not.toContain("Process exited with code"); - expect(item.detail.output).not.toContain("Original token count:"); - expect(item.detail.output).not.toContain("Output:"); - } }); it("maps running known tool variants with detail for early summaries", () => { @@ -132,7 +141,7 @@ describe("codex tool-call mapper", () => { }, { cwd: "/tmp/repo" }, ); - expect(readItem?.detail).toEqual({ + expect(expectMapped(readItem).detail).toEqual({ type: "read", filePath: "README.md", }); @@ -148,7 +157,7 @@ describe("codex tool-call mapper", () => { }, { cwd: "/tmp/repo" }, ); - expect(writeItem?.detail).toEqual({ + expect(expectMapped(writeItem).detail).toEqual({ type: "write", filePath: "src/new.ts", }); @@ -164,7 +173,7 @@ describe("codex tool-call mapper", () => { }, { cwd: "/tmp/repo" }, ); - expect(editItem?.detail).toEqual({ + expect(expectMapped(editItem).detail).toEqual({ type: "edit", filePath: "src/index.ts", }); @@ -176,7 +185,7 @@ describe("codex tool-call mapper", () => { query: "codex timeline", action: null, }); - expect(searchItem?.detail).toEqual({ + expect(expectMapped(searchItem).detail).toEqual({ type: "search", query: "codex timeline", toolName: "web_search", @@ -271,153 +280,163 @@ describe("codex tool-call mapper", () => { }); it("maps mcp read_file completion with detail", () => { - const item = mapCodexToolCallFromThreadItem( - { - type: "mcpToolCall", - id: "codex-call-2", - status: "completed", - tool: "read_file", - arguments: { path: "/tmp/repo/README.md" }, - result: { content: "hello" }, - }, - { cwd: "/tmp/repo" }, + const item = expectMapped( + mapCodexToolCallFromThreadItem( + { + type: "mcpToolCall", + id: "codex-call-2", + status: "completed", + tool: "read_file", + arguments: { path: "/tmp/repo/README.md" }, + result: { content: "hello" }, + }, + { cwd: "/tmp/repo" }, + ), ); - expect(item).toBeTruthy(); - expect(item?.status).toBe("completed"); - expect(item?.error).toBeNull(); - expect(item?.callId).toBe("codex-call-2"); - expect(item?.name).toBe("read_file"); - expect(item?.detail?.type).toBe("read"); - if (item?.detail?.type === "read") { - expect(item.detail.filePath).toBe("README.md"); - expect(item.detail.content).toBe("hello"); - } + expect(item).toEqual({ + type: "tool_call", + callId: "codex-call-2", + name: "read_file", + status: "completed", + error: null, + detail: { + type: "read", + filePath: "README.md", + content: "hello", + }, + }); }); it("retains read_file content when provider returns content array objects", () => { - const item = mapCodexToolCallFromThreadItem( - { - type: "mcpToolCall", - id: "codex-read-array", - status: "completed", - tool: "read_file", - arguments: { path: "/tmp/repo/README.md" }, - result: { - content: [ - { type: "text", text: "line one" }, - { type: "text", text: "line two" }, - ], + const item = expectMapped( + mapCodexToolCallFromThreadItem( + { + type: "mcpToolCall", + id: "codex-read-array", + status: "completed", + tool: "read_file", + arguments: { path: "/tmp/repo/README.md" }, + result: { + content: [ + { type: "text", text: "line one" }, + { type: "text", text: "line two" }, + ], + }, }, - }, - { cwd: "/tmp/repo" }, + { cwd: "/tmp/repo" }, + ), ); - expect(item).toBeTruthy(); - expect(item?.detail?.type).toBe("read"); - if (item?.detail?.type === "read") { - expect(item.detail.filePath).toBe("README.md"); - expect(item.detail.content).toBe("line one\nline two"); - } + expect(item.detail).toEqual({ + type: "read", + filePath: "README.md", + content: "line one\nline two", + }); }); it("truncates large diff payloads deterministically in canonical detail", () => { const hugeDiff = `@@\\n-${"a".repeat(14_000)}\\n+${"b".repeat(14_000)}\\n`; - const item = mapCodexToolCallFromThreadItem( - { - type: "fileChange", - id: "codex-diff-1", - status: "completed", - changes: [{ path: "/tmp/repo/src/index.ts", kind: "modify", diff: hugeDiff }], - }, - { cwd: "/tmp/repo" }, + const item = expectMapped( + mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-diff-1", + status: "completed", + changes: [{ path: "/tmp/repo/src/index.ts", kind: "modify", diff: hugeDiff }], + }, + { cwd: "/tmp/repo" }, + ), ); - expect(item).toBeTruthy(); - expect(item?.status).toBe("completed"); - expect(item?.detail?.type).toBe("edit"); - if (item?.detail?.type === "edit") { - expect(item.detail.unifiedDiff).toBeDefined(); - expect(item.detail.unifiedDiff?.includes("...[truncated ")).toBe(true); - expect((item.detail.unifiedDiff?.length ?? 0) < hugeDiff.length).toBe(true); - } + expect(item.status).toBe("completed"); + expect(item.detail.type).toBe("edit"); + expect(item.detail).toMatchInlineSnapshot(` + { + "filePath": "src/index.ts", + "type": "edit", + "unifiedDiff": "@@\\n-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + ...[truncated 27 chars]", + } + `); + expect(String(item.detail.unifiedDiff).length).toBeLessThan(hugeDiff.length); }); it("maps fileChange content fallback into editable text when unified diff is absent", () => { - const item = mapCodexToolCallFromThreadItem( - { - type: "fileChange", - id: "codex-content-1", - status: "completed", - changes: [ - { - path: "/tmp/repo/src/content-only.ts", - kind: "modify", - content: "line one\nline two\n", - }, - ], - }, - { cwd: "/tmp/repo" }, + const item = expectMapped( + mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-content-1", + status: "completed", + changes: [ + { + path: "/tmp/repo/src/content-only.ts", + kind: "modify", + content: "line one\nline two\n", + }, + ], + }, + { cwd: "/tmp/repo" }, + ), ); - expect(item).toBeTruthy(); - expect(item?.detail?.type).toBe("edit"); - if (item?.detail?.type === "edit") { - expect(item.detail.filePath).toBe("src/content-only.ts"); - expect(item.detail.newString).toContain("line one"); - expect(item.detail.unifiedDiff).toBeUndefined(); - } + expect(item.detail).toEqual({ + type: "edit", + filePath: "src/content-only.ts", + newString: "line one\nline two\n", + }); }); it("maps fileChange object-style change payloads keyed by path", () => { - const item = mapCodexToolCallFromThreadItem( - { - type: "fileChange", - id: "codex-content-object-map", - status: "completed", - changes: { - "/tmp/repo/src/object-map.ts": { - type: "modify", - unified_diff: "@@\n-old\n+new\n", + const item = expectMapped( + mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-content-object-map", + status: "completed", + changes: { + "/tmp/repo/src/object-map.ts": { + type: "modify", + unified_diff: "@@\n-old\n+new\n", + }, }, }, - }, - { cwd: "/tmp/repo" }, + { cwd: "/tmp/repo" }, + ), ); - expect(item).toBeTruthy(); - expect(item?.detail?.type).toBe("edit"); - if (item?.detail?.type === "edit") { - expect(item.detail.filePath).toBe("src/object-map.ts"); - expect(item.detail.unifiedDiff).toContain("-old"); - expect(item.detail.unifiedDiff).toContain("+new"); - } + expect(item.detail).toEqual({ + type: "edit", + filePath: "src/object-map.ts", + unifiedDiff: "@@\n-old\n+new\n", + }); }); it("maps fileChange array payloads that use file_path aliases", () => { - const item = mapCodexToolCallFromThreadItem( - { - type: "fileChange", - id: "codex-content-file-path-alias", - status: "completed", - changes: [ - { - file_path: "/tmp/repo/src/file-path-alias.ts", - kind: "modify", - patch: "@@\n-before\n+after\n", - }, - ], - }, - { cwd: "/tmp/repo" }, + const item = expectMapped( + mapCodexToolCallFromThreadItem( + { + type: "fileChange", + id: "codex-content-file-path-alias", + status: "completed", + changes: [ + { + file_path: "/tmp/repo/src/file-path-alias.ts", + kind: "modify", + patch: "@@\n-before\n+after\n", + }, + ], + }, + { cwd: "/tmp/repo" }, + ), ); - expect(item).toBeTruthy(); - expect(item?.detail?.type).toBe("edit"); - if (item?.detail?.type === "edit") { - expect(item.detail.filePath).toBe("src/file-path-alias.ts"); - expect(item.detail.unifiedDiff).toContain("-before"); - expect(item.detail.unifiedDiff).toContain("+after"); - } + expect(item.detail).toEqual({ + type: "edit", + filePath: "src/file-path-alias.ts", + unifiedDiff: "@@\n-before\n+after\n", + }); }); it("maps write/edit/search known variants with distinct detail types", () => { @@ -432,10 +451,11 @@ describe("codex tool-call mapper", () => { }, { cwd: "/tmp/repo" }, ); - expect(writeItem?.detail?.type).toBe("write"); - if (writeItem?.detail?.type === "write") { - expect(writeItem.detail.filePath).toBe("src/new.ts"); - } + expect(expectMapped(writeItem).detail).toEqual({ + type: "write", + filePath: "src/new.ts", + content: "export {}", + }); const editItem = mapCodexToolCallFromThreadItem( { @@ -448,10 +468,11 @@ describe("codex tool-call mapper", () => { }, { cwd: "/tmp/repo" }, ); - expect(editItem?.detail?.type).toBe("edit"); - if (editItem?.detail?.type === "edit") { - expect(editItem.detail.filePath).toBe("src/index.ts"); - } + expect(expectMapped(editItem).detail).toEqual({ + type: "edit", + filePath: "src/index.ts", + unifiedDiff: "@@\\n-a\\n+b\\n", + }); const searchItem = mapCodexToolCallFromThreadItem({ type: "webSearch", @@ -460,7 +481,7 @@ describe("codex tool-call mapper", () => { query: "codex timeline", action: { results: [] }, }); - expect(searchItem?.detail).toEqual({ + expect(expectMapped(searchItem).detail).toEqual({ type: "search", query: "codex timeline", toolName: "web_search", @@ -468,26 +489,36 @@ describe("codex tool-call mapper", () => { }); it("maps failed tool calls with required error", () => { - const item = mapCodexToolCallFromThreadItem({ - type: "mcpToolCall", - id: "codex-call-3", - status: "failed", - server: "custom", - tool: "run", - arguments: { foo: "bar" }, - result: null, - error: { message: "boom" }, - }); + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "mcpToolCall", + id: "codex-call-3", + status: "failed", + server: "custom", + tool: "run", + arguments: { foo: "bar" }, + result: null, + error: { message: "boom" }, + }), + ); - expect(item).toBeTruthy(); - expect(item?.status).toBe("failed"); - expect(item?.error).toEqual({ message: "boom" }); - expect(item?.callId).toBe("codex-call-3"); + expect(item).toEqual({ + type: "tool_call", + callId: "codex-call-3", + name: "custom.run", + status: "failed", + error: { message: "boom" }, + detail: { + type: "unknown", + input: { foo: "bar" }, + output: null, + }, + }); }); it("maps unknown tools to unknown detail with raw payloads", () => { const item = expectMapped( - mapCodexRolloutToolCall({ + mapCodexToolCallEnvelope({ callId: "codex-call-4", name: "my_custom_tool", input: { foo: "bar" }, @@ -505,7 +536,7 @@ describe("codex tool-call mapper", () => { expect(item.callId).toBe("codex-call-4"); }); - it("maps apply_patch rollout calls with raw patch input into edit detail", () => { + it("maps apply_patch tool-call calls with raw patch input into edit detail", () => { const patch = [ "*** Begin Patch", "*** Update File: /tmp/repo/src/index.ts", @@ -515,7 +546,7 @@ describe("codex tool-call mapper", () => { "*** End Patch", ].join("\n"); const item = expectMapped( - mapCodexRolloutToolCall({ + mapCodexToolCallEnvelope({ callId: "codex-call-apply", name: "apply_patch", input: patch, @@ -526,16 +557,18 @@ describe("codex tool-call mapper", () => { expect(item.status).toBe("completed"); expect(item.error).toBeNull(); - expect(item.detail.type).toBe("edit"); - if (item.detail.type === "edit") { - expect(item.detail.filePath).toBe("src/index.ts"); - expect(item.detail.unifiedDiff).toContain("diff --git"); - expect(item.detail.unifiedDiff).toContain("@@"); - expect(item.detail.unifiedDiff).toContain("-old"); - expect(item.detail.unifiedDiff).toContain("+new"); - expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch"); - expect(item.detail.newString).toBeUndefined(); - } + expect(item.detail).toMatchInlineSnapshot(` + { + "filePath": "src/index.ts", + "type": "edit", + "unifiedDiff": "diff --git a//tmp/repo/src/index.ts b//tmp/repo/src/index.ts + --- a//tmp/repo/src/index.ts + +++ b//tmp/repo/src/index.ts + @@ + -old + +new", + } + `); }); it("maps apply_patch object content payloads into unified diff detail", () => { @@ -549,7 +582,7 @@ describe("codex tool-call mapper", () => { ].join("\n"); const item = expectMapped( - mapCodexRolloutToolCall({ + mapCodexToolCallEnvelope({ callId: "codex-call-apply-object", name: "apply_patch", input: { @@ -561,16 +594,18 @@ describe("codex tool-call mapper", () => { }), ); - expect(item.detail.type).toBe("edit"); - if (item.detail.type === "edit") { - expect(item.detail.filePath).toBe("src/object.ts"); - expect(item.detail.unifiedDiff).toContain("diff --git"); - expect(item.detail.unifiedDiff).toContain("@@"); - expect(item.detail.unifiedDiff).toContain("-before"); - expect(item.detail.unifiedDiff).toContain("+after"); - expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch"); - expect(item.detail.newString).toBeUndefined(); - } + expect(item.detail).toMatchInlineSnapshot(` + { + "filePath": "src/object.ts", + "type": "edit", + "unifiedDiff": "diff --git a//tmp/repo/src/object.ts b//tmp/repo/src/object.ts + --- a//tmp/repo/src/object.ts + +++ b//tmp/repo/src/object.ts + @@ + -before + +after", + } + `); }); it("maps fileChange content that contains codex patch envelopes as unified diffs", () => { @@ -593,14 +628,18 @@ describe("codex tool-call mapper", () => { { cwd: "/tmp/repo" }, ); - expect(item?.detail?.type).toBe("edit"); - if (item?.detail?.type === "edit") { - expect(item.detail.filePath).toBe("src/from-file-change.ts"); - expect(item.detail.unifiedDiff).toContain("-alpha"); - expect(item.detail.unifiedDiff).toContain("+beta"); - expect(item.detail.unifiedDiff).not.toContain("*** Begin Patch"); - expect(item.detail.newString).toBeUndefined(); - } + expect(expectMapped(item).detail).toMatchInlineSnapshot(` + { + "filePath": "src/from-file-change.ts", + "type": "edit", + "unifiedDiff": "diff --git a//tmp/repo/src/from-file-change.ts b//tmp/repo/src/from-file-change.ts + --- a//tmp/repo/src/from-file-change.ts + +++ b//tmp/repo/src/from-file-change.ts + @@ + -alpha + +beta", + } + `); }); it("maps path-only fileChange payloads to unknown detail instead of empty edit detail", () => { @@ -614,45 +653,50 @@ describe("codex tool-call mapper", () => { { cwd: "/tmp/repo" }, ); - expect(item?.detail.type).toBe("unknown"); - if (item?.detail.type === "unknown") { - expect(item.detail.input).toEqual({ + expect(expectMapped(item).detail).toEqual({ + type: "unknown", + input: { files: [{ path: "src/path-only.ts", kind: "modify" }], - }); - } + }, + output: { + files: [{ path: "src/path-only.ts", kind: "modify" }], + }, + }); }); - it("maps path-only apply_patch rollout payloads to unknown detail instead of empty edit detail", () => { + it("maps path-only apply_patch tool-call payloads to unknown detail instead of empty edit detail", () => { const item = expectMapped( - mapCodexRolloutToolCall({ + mapCodexToolCallEnvelope({ callId: "codex-call-apply-path-only", name: "apply_patch", - input: { path: "/tmp/repo/src/path-only-rollout.ts" }, - output: { files: [{ path: "/tmp/repo/src/path-only-rollout.ts", kind: "modify" }] }, + input: { path: "/tmp/repo/src/path-only-tool-call.ts" }, + output: { files: [{ path: "/tmp/repo/src/path-only-tool-call.ts", kind: "modify" }] }, cwd: "/tmp/repo", }), ); - expect(item.detail.type).toBe("unknown"); - if (item.detail.type === "unknown") { - expect(item.detail.input).toEqual({ path: "/tmp/repo/src/path-only-rollout.ts" }); - } + expect(item.detail).toEqual({ + type: "unknown", + input: { path: "/tmp/repo/src/path-only-tool-call.ts" }, + output: { files: [{ path: "/tmp/repo/src/path-only-tool-call.ts", kind: "modify" }] }, + }); }); it("normalizes codex paseo speak mcp calls and extracts spoken text", () => { - const item = mapCodexToolCallFromThreadItem({ - type: "mcpToolCall", - id: "codex-speak-thread-1", - status: "completed", - server: "paseo", - tool: "speak", - arguments: { text: "Voice response from Codex." }, - result: { ok: true }, - }); + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "mcpToolCall", + id: "codex-speak-thread-1", + status: "completed", + server: "paseo", + tool: "speak", + arguments: { text: "Voice response from Codex." }, + result: { ok: true }, + }), + ); - expect(item).toBeTruthy(); - expect(item?.name).toBe("speak"); - expect(item?.detail).toEqual({ + expect(item.name).toBe("speak"); + expect(item.detail).toEqual({ type: "unknown", input: "Voice response from Codex.", output: null, @@ -660,31 +704,32 @@ describe("codex tool-call mapper", () => { }); it("normalizes codex paseo_voice.speak mcp calls and extracts spoken text", () => { - const item = mapCodexToolCallFromThreadItem({ - type: "mcpToolCall", - id: "codex-speak-thread-2", - status: "completed", - server: "paseo_voice", - tool: "speak", - arguments: { text: "Voice response from Codex via paseo_voice." }, - result: { ok: true }, - }); + const item = expectMapped( + mapCodexToolCallFromThreadItem({ + type: "mcpToolCall", + id: "codex-speak-thread-2", + status: "completed", + server: "paseo_voice", + tool: "speak", + arguments: { text: "Voice response from Codex via paseo_voice." }, + result: { ok: true }, + }), + ); - expect(item).toBeTruthy(); - expect(item?.name).toBe("speak"); - expect(item?.detail).toEqual({ + expect(item.name).toBe("speak"); + expect(item.detail).toEqual({ type: "unknown", input: "Voice response from Codex via paseo_voice.", output: null, }); }); - it("normalizes codex paseo speak rollout names and extracts spoken text", () => { + it("normalizes codex paseo speak tool-call names and extracts spoken text", () => { const item = expectMapped( - mapCodexRolloutToolCall({ - callId: "codex-speak-rollout-1", + mapCodexToolCallEnvelope({ + callId: "codex-speak-tool-call-1", name: "paseo.speak", - input: { text: "Rollout speech text." }, + input: { text: "Tool call speech text." }, output: { ok: true }, }), ); @@ -692,13 +737,13 @@ describe("codex tool-call mapper", () => { expect(item.name).toBe("speak"); expect(item.detail).toEqual({ type: "unknown", - input: "Rollout speech text.", + input: "Tool call speech text.", output: null, }); }); - it("drops rollout tool calls when callId is missing", () => { - const item = mapCodexRolloutToolCall({ + it("drops tool-call tool calls when callId is missing", () => { + const item = mapCodexToolCallEnvelope({ callId: null, name: "read_file", input: { path: "/tmp/repo/README.md" }, @@ -727,8 +772,8 @@ describe("codex tool-call mapper", () => { "*** End Patch", ].join("\n"); const item = expectMapped( - mapCodexRolloutToolCall({ - callId: "codex-delete-rollout", + mapCodexToolCallEnvelope({ + callId: "codex-delete-tool-call", name: "apply_patch", input: patch, output: @@ -738,15 +783,19 @@ describe("codex tool-call mapper", () => { ); expect(item.status).toBe("completed"); - expect(item.detail.type).toBe("edit"); - if (item.detail.type === "edit") { - expect(item.detail.filePath).toBe("src/dead-module.ts"); - expect(item.detail.unifiedDiff).toContain("/dev/null"); - } + expect(item.detail).toMatchInlineSnapshot(` + { + "filePath": "src/dead-module.ts", + "type": "edit", + "unifiedDiff": "diff --git a//tmp/repo/src/dead-module.ts b//tmp/repo/src/dead-module.ts + --- a//tmp/repo/src/dead-module.ts + +++ /dev/null", + } + `); }); it("maps multi-file apply_patch with update + delete into edit detail referencing the deleted file", () => { - // Exact data shape from real Codex rollout: update one file, delete another + // Exact data shape from real Codex tool-call: update one file, delete another const patch = [ "*** Begin Patch", "*** Update File: /tmp/repo/src/app/index.tsx", @@ -760,7 +809,7 @@ describe("codex tool-call mapper", () => { ].join("\n"); const item = expectMapped( - mapCodexRolloutToolCall({ + mapCodexToolCallEnvelope({ callId: "codex-delete-multi", name: "apply_patch", input: patch, @@ -773,16 +822,24 @@ describe("codex tool-call mapper", () => { }), ); - expect(item.detail.type).toBe("edit"); - if (item.detail.type === "edit") { - // The unified diff should contain both file sections - const diff = item.detail.unifiedDiff ?? ""; - // The update file section should have normal diff lines - expect(diff).toContain("-import"); - expect(diff).toContain("+const WELCOME_ROUTE"); - // The delete file section should reference /dev/null - expect(diff).toContain("/dev/null"); - } + expect(item.detail).toMatchInlineSnapshot(` + { + "filePath": "src/app/index.tsx", + "type": "edit", + "unifiedDiff": "diff --git a//tmp/repo/src/app/index.tsx b//tmp/repo/src/app/index.tsx + --- a//tmp/repo/src/app/index.tsx + +++ b//tmp/repo/src/app/index.tsx + @@ + import { useEffect } from "react"; + -import { WELCOME_ROUTE } from "@/app-support/index-startup"; + + + +const WELCOME_ROUTE = "/welcome"; + + diff --git a//tmp/repo/src/app-support/index-startup.ts b//tmp/repo/src/app-support/index-startup.ts + --- a//tmp/repo/src/app-support/index-startup.ts + +++ /dev/null", + } + `); }); it("maps fileChange delete with content as removed lines, not added lines", () => { @@ -802,20 +859,18 @@ describe("codex tool-call mapper", () => { { cwd: "/tmp/repo" }, ); - expect(item).toBeTruthy(); - expect(item?.detail.type).toBe("edit"); - if (item?.detail.type === "edit") { - expect(item.detail.filePath).toBe("src/dead-module.ts"); - const diff = item.detail.unifiedDiff ?? ""; - // For a deletion, the content should appear as REMOVED lines (-) - // not as ADDED lines (+). This is the core bug. - expect(diff).toContain("/dev/null"); - expect(diff).toContain("-export const FOO"); - expect(diff).toContain("-export function hello"); - // The content must NOT appear as added lines - expect(diff).not.toContain("+export const FOO"); - expect(diff).not.toContain("+export function hello"); - } + expect(expectMapped(item).detail).toMatchInlineSnapshot(` + { + "filePath": "src/dead-module.ts", + "type": "edit", + "unifiedDiff": "diff --git a/src/dead-module.ts b/src/dead-module.ts + --- a/src/dead-module.ts + +++ /dev/null + @@ -1,2 +0,0 @@ + -export const FOO = "bar"; + -export function hello() {}", + } + `); }); it("maps fileChange delete without content to edit detail with /dev/null marker", () => { @@ -834,12 +889,15 @@ describe("codex tool-call mapper", () => { { cwd: "/tmp/repo" }, ); - expect(item).toBeTruthy(); - // A delete without content should still produce a meaningful detail - if (item?.detail.type === "edit") { - expect(item.detail.filePath).toBe("src/dead-module.ts"); - const diff = item.detail.unifiedDiff ?? ""; - expect(diff).toContain("/dev/null"); - } + // A delete without content should still produce a meaningful detail. + expect(expectMapped(item).detail).toMatchInlineSnapshot(` + { + "filePath": "src/dead-module.ts", + "type": "edit", + "unifiedDiff": "diff --git a/src/dead-module.ts b/src/dead-module.ts + --- a/src/dead-module.ts + +++ /dev/null", + } + `); }); }); diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index cc6e0d4ca..38cb94126 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -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; -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 | undefined { +function findToolCallEditPatchText(input: Record): string | undefined { return ( (typeof input.patch === "string" && input.patch) || (typeof input.diff === "string" && input.diff) || @@ -443,7 +443,7 @@ function findRolloutEditPatchText(input: Record): string | unde ); } -function findRolloutEditInputPath( +function findToolCallEditInputPath( input: Record, patchText: string, ): string | undefined { @@ -459,14 +459,14 @@ function findRolloutEditInputPath( ); } -function normalizeRolloutEditRecordInput(input: Record): unknown { - const candidatePatchText = findRolloutEditPatchText(input); +function normalizeToolCallEditRecordInput(input: Record): 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): 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({ diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts index 81f0bb4bb..2c3122dd3 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -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"; diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index a99c4ed0e..aac74df1c 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -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(); 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 { @@ -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 { + this.failConfiguredRewind(); + this.keepFirstUserMessageHistory(); + } + + async revertFiles(_input: { messageId: string }): Promise { + this.failConfiguredRewind(); + this.keepFirstUserMessageHistory(); + } + + async revertBoth(_input: { messageId: string }): Promise { + this.failConfiguredRewind(); + this.keepFirstUserMessageHistory(); + } + async setModel(modelId: string | null): Promise { 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; diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 570646780..e12fdb088 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -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: [ { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index ca85cba57..2ef9b9e2a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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, 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, + 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, @@ -1352,6 +1388,8 @@ export interface OpenCodeEventTranslationState { sessionId: string; cwd?: string; messageRoles: Map; + pendingUserMessageText?: string | null; + emittedUserMessageIds?: Set; accumulatedUsage: AgentUsage; sessionTotalCostUsd?: number; streamedPartKeys: Set; @@ -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, + 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, state: OpenCodeEventTranslationState, @@ -2322,6 +2381,8 @@ class OpenCodeAgentSession implements AgentSession { private mcpSetupPromise: Promise | null = null; /** Tracks the role of each message by ID to distinguish user from assistant messages */ private messageRoles = new Map(); + private pendingUserMessageText: string | null = null; + private emittedUserMessageIds = new Set(); /** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */ private streamedPartKeys = new Set(); /** Tracks assistant messages already emitted from structured payloads. */ @@ -2432,6 +2493,15 @@ class OpenCodeAgentSession implements AgentSession { } } + async revertBoth(input: { messageId: string }): Promise { + await revertOpenCodeConversationAndFiles({ + client: this.client, + sessionId: this.sessionId, + cwd: this.config.cwd, + messageId: input.messageId, + }); + } + private beginSessionAbort(turnId: string | null, reason: string): Promise { 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 { + 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, diff --git a/packages/server/src/server/agent/providers/opencode/rewind.test.ts b/packages/server/src/server/agent/providers/opencode/rewind.test.ts new file mode 100644 index 000000000..18baba3a3 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/rewind.test.ts @@ -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> = {}; + + 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, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/rewind.ts b/packages/server/src/server/agent/providers/opencode/rewind.ts new file mode 100644 index 000000000..a6ed04f1d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/rewind.ts @@ -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 { + // 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)); + } +} diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 581b39f7f..559ad41bc 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -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 { @@ -31,6 +29,17 @@ function createConfig(overrides: Partial = {}): 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> { + return this.nextEvent( + (event): event is Extract => + event.type === "timeline", + ); + } + private nextEvent( predicate: (event: AgentStreamEvent) => event is T, ): Promise { @@ -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; }; 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); }); }); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 182d0e34d..bf6de600f 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -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): 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): 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): 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 { @@ -411,6 +578,46 @@ function optionalString(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function parseExtensionMarkerPayload( + message: string, + marker: string, +): Record | 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, ): AgentPermissionRequest | null { @@ -533,6 +740,12 @@ export class PiRpcAgentSession implements AgentSession { private readonly pendingExtensionUiRequests = new Map(); private activeTurnId: string | null = null; private lastKnownThinkingOptionId: string | null; + currentLeafOverrideId: string | null | undefined; + private readonly capturedUserEntries: PiCapturedEntry[] = []; + private readonly capturedUserEntriesById = new Map(); + private readonly seenUserEntryIds = new Set(); + private readonly pendingUserMessages: PendingPiUserMessage[] = []; + private readonly pendingExtensionResults = new Map(); private state: PiSessionState; private closed = false; @@ -612,7 +825,12 @@ export class PiRpcAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { - 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 { @@ -683,6 +901,35 @@ export class PiRpcAgentSession implements AgentSession { await this.runtimeSession.abort(); } + async revertConversation(input: { messageId: string }): Promise { + 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 { + 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 { 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 { + 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 { + 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): 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, ): 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, + 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 { 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 { - return await listPiPersistedAgents({ - ...options, - runtimeSettings: this.runtimeSettings, - }); + return []; } async isAvailable(): Promise { diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts index 1851204b7..75c9ad77a 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.test.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.test.ts @@ -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 { +async function collectHistory( + messages: PiAgentMessage[], + userEntries: PiCapturedUserMessageEntry[] = [], +): Promise { 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", + }, + }, + ]); + }); }); diff --git a/packages/server/src/server/agent/providers/pi/history-mapper.ts b/packages/server/src/server/agent/providers/pi/history-mapper.ts index f8440f901..b6653ece5 100644 --- a/packages/server/src/server/agent/providers/pi/history-mapper.ts +++ b/packages/server/src/server/agent/providers/pi/history-mapper.ts @@ -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 { const pendingToolCalls = new Map(); 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 } : {}), }, }; } diff --git a/packages/server/src/server/agent/providers/pi/rewind.ts b/packages/server/src/server/agent/providers/pi/rewind.ts new file mode 100644 index 000000000..82004ad96 --- /dev/null +++ b/packages/server/src/server/agent/providers/pi/rewind.ts @@ -0,0 +1,14 @@ +export interface PiRewindNavigator { + navigateTree(targetId: string): Promise; +} + +export async function revertPiConversation(input: { + messageId: string; + navigator: PiRewindNavigator; +}): Promise { + const targetId = input.messageId.trim(); + if (!targetId) { + throw new Error("Pi rewind requires a user message id"); + } + await input.navigator.navigateTree(targetId); +} diff --git a/packages/server/src/server/agent/providers/pi/rpc-types.ts b/packages/server/src/server/agent/providers/pi/rpc-types.ts index 93efad32b..aab5f9424 100644 --- a/packages/server/src/server/agent/providers/pi/rpc-types.ts +++ b/packages/server/src/server/agent/providers/pi/rpc-types.ts @@ -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; diff --git a/packages/server/src/server/agent/providers/pi/runtime.ts b/packages/server/src/server/agent/providers/pi/runtime.ts index f119bad6a..e479c5894 100644 --- a/packages/server/src/server/agent/providers/pi/runtime.ts +++ b/packages/server/src/server/agent/providers/pi/runtime.ts @@ -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, }; } diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts deleted file mode 100644 index 9665fe8be..000000000 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.test.ts +++ /dev/null @@ -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"]); - }); -}); diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.ts deleted file mode 100644 index b1e6d9918..000000000 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 | 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 { - 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; -} diff --git a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts index 7cee9d669..08d92f201 100644 --- a/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts +++ b/packages/server/src/server/agent/providers/pi/test-utils/fake-pi.ts @@ -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 { this.prompts.push({ message, imageCount: images?.length ?? 0 }); + this.handleTreeNavigationCommand(message); + this.handleEntryCaptureCommand(message); } async abort(): Promise { @@ -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 })}`, + }); + } } diff --git a/packages/server/src/server/agent/providers/provider-availability.test.ts b/packages/server/src/server/agent/providers/provider-availability.test.ts index d5f2c9564..baf0cf7f9 100644 --- a/packages/server/src/server/agent/providers/provider-availability.test.ts +++ b/packages/server/src/server/agent/providers/provider-availability.test.ts @@ -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 }); diff --git a/packages/server/src/server/agent/rewind/rewind.test.ts b/packages/server/src/server/agent/rewind/rewind.test.ts new file mode 100644 index 000000000..c8eac014e --- /dev/null +++ b/packages/server/src/server/agent/rewind/rewind.test.ts @@ -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 { + return this.session; + } + + async resumeSession(): Promise { + return this.session; + } + + async listModels(_options: ListModelsOptions) { + return []; + } + + async isAvailable() { + return true; + } +} + +class RewindHistoryGate { + private gate: Promise | null = null; + private releaseGate: (() => void) | null = null; + + hold(): void { + this.gate = new Promise((resolve) => { + this.releaseGate = resolve; + }); + } + + release(): void { + this.releaseGate?.(); + this.releaseGate = null; + this.gate = null; + } + + async wait(): Promise { + 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; + }); +}); diff --git a/packages/server/src/server/agent/rewind/rewind.ts b/packages/server/src/server/agent/rewind/rewind.ts new file mode 100644 index 000000000..4c9899e87 --- /dev/null +++ b/packages/server/src/server/agent/rewind/rewind.ts @@ -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 { + 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; + } +} diff --git a/packages/server/src/server/agent/rewind/test-rewind-session.ts b/packages/server/src/server/agent/rewind/test-rewind-session.ts new file mode 100644 index 000000000..e717ca051 --- /dev/null +++ b/packages/server/src/server/agent/rewind/test-rewind-session.ts @@ -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) {} + + async run(): Promise { + 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 { + 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 {} + + getPendingPermissions(): AgentPermissionRequest[] { + return []; + } + + async respondToPermission( + _requestId: string, + _response: AgentPermissionResponse, + ): Promise {} + + describePersistence(): AgentPersistenceHandle { + return { provider: this.provider, sessionId: this.id }; + } + + async interrupt(): Promise { + 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 {} + + async revertConversation(input: { messageId: string }): Promise { + this.recordedRewinds.push({ mode: "conversation", messageId: input.messageId }); + } + + async revertFiles(input: { messageId: string }): Promise { + this.recordedRewinds.push({ mode: "files", messageId: input.messageId }); + } + + async revertBoth(input: { messageId: string }): Promise { + this.recordedRewinds.push({ mode: "both", messageId: input.messageId }); + } + + private emit(event: AgentStreamEvent): void { + for (const subscriber of this.subscribers) { + subscriber(event); + } + } +} diff --git a/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts new file mode 100644 index 000000000..8f43cad58 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-rewind.real.e2e.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +}); diff --git a/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts new file mode 100644 index 000000000..493e33012 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/codex-rewind.real.e2e.test.ts @@ -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 { + 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 { + 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 { + await expect(createdFilesAtCwd(session)).resolves.toEqual([...expectedNames].sort()); +} + +async function launchCodexRewindSession( + harness: CodexRewindHarness, + title: string, +): Promise { + 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 { + 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 { + 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); +}); diff --git a/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts new file mode 100644 index 000000000..dbb2d91b4 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/opencode-rewind.real.e2e.test.ts @@ -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 { + 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 { + 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 { + const snapshot = await harness.client.fetchAgent(session.agentId); + expect(snapshot?.agent.status).toBe("idle"); +} + +async function createdFilesAtCwd(session: OpenCodeRewindSession): Promise { + 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 { + await expect(createdFilesAtCwd(session)).resolves.toEqual([...expectedNames].sort()); +} + +async function closeOpenCodeRewindSession(session: OpenCodeRewindSession): Promise { + 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 { + 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 { + 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); +}); diff --git a/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts new file mode 100644 index 000000000..939e17f47 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/pi-rewind.real.e2e.test.ts @@ -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 { + 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 { + closeRewindSession(session); +} + +async function runtimeSessionId( + harness: PiRewindHarness, + session: PiRewindSession, +): Promise { + 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 { + await expect(readdir(session.cwd)).resolves.toEqual([]); +} + +async function askPi( + harness: PiRewindHarness, + session: PiRewindSession, + input: { promptToken: string; doneToken: string }, +): Promise { + 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); +}); diff --git a/packages/server/src/server/daemon-e2e/test-utils/rewind-helpers.ts b/packages/server/src/server/daemon-e2e/test-utils/rewind-helpers.ts new file mode 100644 index 000000000..4c06f7954 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/test-utils/rewind-helpers.ts @@ -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>, +): 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 { + const timeline = await client.fetchAgentTimeline(agentId, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + return timelineItems(timeline); +} + +export async function readScratchFile(session: { scratchPath: string }): Promise { + return await readFile(session.scratchPath, "utf8"); +} + +export async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +export async function waitForTimelineItems( + client: DaemonClient, + agentId: string, + predicate: (items: AgentTimelineItem[]) => boolean, +): Promise { + const existingItems = await fetchTimelineItems(client, agentId); + if (predicate(existingItems)) { + return existingItems; + } + + return await new Promise((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); + } + })(); + }); + }); +} diff --git a/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts new file mode 100644 index 000000000..7cbefbeb5 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/user-message-contract.real.e2e.test.ts @@ -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; + +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, + ); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c7f41183c..4bf3a8174 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1775,6 +1775,7 @@ export class Session { private async dispatchInboundMessage(msg: SessionInboundMessage): Promise { 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 | undefined { + switch (msg.type) { + case "agent.rewind.request": + return this.handleAgentRewindRequest(msg); + default: + return undefined; + } + } + private async handleDictationStreamStart( msg: Extract, ): Promise { @@ -3522,6 +3532,33 @@ export class Session { } } + private async handleAgentRewindRequest( + msg: Extract, + ): Promise { + 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, }); }, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 559a2c381..7342b7e3c 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -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"; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 9f9862eb4..a8098a794 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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, }, }; } diff --git a/packages/server/src/shared/agent-feature-schemas.test.ts b/packages/server/src/shared/agent-feature-schemas.test.ts index 5a01bf75d..1eb5d6e50 100644 --- a/packages/server/src/shared/agent-feature-schemas.test.ts +++ b/packages/server/src/shared/agent-feature-schemas.test.ts @@ -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); + }); }); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index c0a607185..f50ec22f7 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -232,6 +232,12 @@ const AgentCapabilityFlagsSchema: z.ZodType = 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 = 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; export type SetAgentThinkingResponseMessage = z.infer; export type SetAgentFeatureResponseMessage = z.infer; +export type AgentRewindResponseMessage = z.infer; export type UpdateAgentResponseMessage = z.infer; export type ProjectRenameResponse = z.infer; export type ProjectRenameResponsePayload = z.infer; diff --git a/packages/server/src/shared/wire-compat.test.ts b/packages/server/src/shared/wire-compat.test.ts index cb0111dd2..f4959753d 100644 --- a/packages/server/src/shared/wire-compat.test.ts +++ b/packages/server/src/shared/wire-compat.test.ts @@ -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; handleFetchAgentTimelineRequest: ( message: Extract< z.infer, @@ -68,12 +81,140 @@ interface SessionInternals { ) => Promise; } +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 | 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: {