Rewind chat or files from any user message (#1154)
* Add Pi extension-launch plumbing for in-process tree primitives
* Add Rewind controls for agent sessions
* Preserve SDK checkpoint env var so Claude file rewind works
The SDK injects CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING=true when file checkpointing is enabled. The earlier parent-env stripping from c4782ec71c was too broad and removed that child flag along with real parent-session markers.
Keep stripping parent markers, but preserve the checkpoint flag so the SDK rewind path can run directly. With that boundary fixed, the JSONL file-rewind fallback is no longer needed.
* Update OpenCode history replay tests for rewind state
* Show Rewind actions in an adaptive sheet
* Fix rewind semantics across providers
* Refactor Pi IDs through extension capture
* Restore user message to composer after rewind
* Fix Claude rewind tool result mapping
* Fix Claude rewind session switch roundtrip
* Fix rewind and optimistic message regressions
* Tighten rewind test discipline
* Fix OpenCode optimistic message reconciliation
* Reconcile optimistic user messages by marker
* Reorder user message actions
* Unify provider user message contract
* Add Claude rewind flow Playwright contract
* Add Codex rewind flow Playwright contract
* Add OpenCode rewind flow Playwright contract
* Add Pi rewind flow Playwright contract
* Remove real-provider rewind Playwright contracts
* Gate real-provider rewind specs from CI Playwright
This commit is contained in:
@@ -28,6 +28,8 @@ Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
---
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
@@ -537,13 +537,22 @@ async function awaitRelayReady(
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(): Promise<number> {
|
||||
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
|
||||
for (;;) {
|
||||
const port = await getAvailablePort();
|
||||
if (!excludedPorts.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): Promise<number> {
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
const relayPort = await getAvailablePort();
|
||||
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||
const buffer = createLineBuffer();
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
@@ -729,7 +738,7 @@ export default async function globalSetup() {
|
||||
const dictation = await resolveDictationConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay();
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expectComposerEditable, submitMessage } from "./composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
||||
|
||||
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
|
||||
export type RewindFlowMode = "conversation" | "files" | "both";
|
||||
|
||||
export interface AgentHandle {
|
||||
page: Page;
|
||||
client: TerminalPerfDaemonClient;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
provider: RewindFlowProvider;
|
||||
}
|
||||
|
||||
export interface TranscriptMessage {
|
||||
role: "user" | "assistant";
|
||||
text: string | RegExp;
|
||||
}
|
||||
|
||||
interface ProviderLaunchConfig {
|
||||
provider: RewindFlowProvider;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
}
|
||||
|
||||
const SEND_TIMEOUT_MS = 240_000;
|
||||
const REWIND_TIMEOUT_MS = 120_000;
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
return { provider, model: "haiku", modeId: "bypassPermissions" };
|
||||
case "codex":
|
||||
return {
|
||||
provider,
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
};
|
||||
case "opencode":
|
||||
return { provider, model: "opencode/big-pickle", modeId: "full-access" };
|
||||
case "pi":
|
||||
return {
|
||||
provider,
|
||||
model: "openrouter/google/gemini-2.5-flash-lite",
|
||||
thinkingOptionId: "medium",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function agentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
await page.goto(agentRoute(input.cwd, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await assertComposerIdle({ page });
|
||||
}
|
||||
|
||||
function visibleChatMessages(page: Page) {
|
||||
return page
|
||||
.locator('[data-testid="agent-chat-scroll"]:visible')
|
||||
.first()
|
||||
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
|
||||
}
|
||||
|
||||
async function transcript(
|
||||
page: Page,
|
||||
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
|
||||
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
|
||||
| "user"
|
||||
| "assistant",
|
||||
text: (element.textContent ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
|
||||
.trim(),
|
||||
})),
|
||||
);
|
||||
|
||||
return coalesceAssistantTurnSegments(rawMessages);
|
||||
}
|
||||
|
||||
function coalesceAssistantTurnSegments(
|
||||
messages: Array<{ role: "user" | "assistant"; text: string }>,
|
||||
): Array<{ role: "user" | "assistant"; text: string }> {
|
||||
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const previous = transcriptMessages.at(-1);
|
||||
if (message.role === "assistant" && previous?.role === "assistant") {
|
||||
const joinedText =
|
||||
previous.text && message.text
|
||||
? `${previous.text}\n${message.text}`
|
||||
: previous.text || message.text;
|
||||
transcriptMessages[transcriptMessages.length - 1] = {
|
||||
role: "assistant",
|
||||
text: joinedText,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
transcriptMessages.push(message);
|
||||
}
|
||||
|
||||
return transcriptMessages.filter(
|
||||
(message) => message.role !== "assistant" || message.text.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
|
||||
if (typeof expected === "string") {
|
||||
return actual === expected;
|
||||
}
|
||||
return expected.test(actual);
|
||||
}
|
||||
|
||||
function formatExpectedMessage(message: TranscriptMessage): string {
|
||||
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
|
||||
return `${message.role}:${text}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
export async function launchAgent(input: {
|
||||
page: Page;
|
||||
provider: RewindFlowProvider;
|
||||
cwd: string;
|
||||
mode: "full-access";
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||
const client = await connectTerminalClient();
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
cwd: input.cwd,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
});
|
||||
const handle = {
|
||||
page: input.page,
|
||||
client,
|
||||
agentId: agent.id,
|
||||
cwd: input.cwd,
|
||||
provider: input.provider,
|
||||
};
|
||||
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function closeAgent(handle: AgentHandle): Promise<void> {
|
||||
await handle.client.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
|
||||
const before = await transcript(handle.page);
|
||||
await submitMessage(handle.page, text);
|
||||
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
|
||||
if (finish.status !== "idle") {
|
||||
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
|
||||
throw new Error(
|
||||
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
|
||||
);
|
||||
}
|
||||
if (finish.final?.lastError) {
|
||||
throw new Error(finish.final.lastError);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page), { timeout: 30_000 })
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "user", text }),
|
||||
expect.objectContaining({ role: "assistant" }),
|
||||
]),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(before.length + 2);
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertChatTranscript(
|
||||
handle: Pick<AgentHandle, "page">,
|
||||
expected: TranscriptMessage[],
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
return JSON.stringify(actual);
|
||||
}
|
||||
const matches = actual.every(
|
||||
(message, index) =>
|
||||
message.role === expected[index]?.role &&
|
||||
expectedTextMatches(message.text, expected[index]!.text),
|
||||
);
|
||||
return matches ? "match" : JSON.stringify(actual);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("match");
|
||||
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
throw new Error(
|
||||
`Expected ${expected.length} chat messages (${expected
|
||||
.map(formatExpectedMessage)
|
||||
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rewindMessage(
|
||||
handle: AgentHandle,
|
||||
userMessageIndex: number,
|
||||
mode: RewindFlowMode,
|
||||
): Promise<void> {
|
||||
const beforeEpoch = await fetchTimelineEpoch(handle);
|
||||
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
|
||||
const userText = userMessages[userMessageIndex]?.text;
|
||||
if (!userText) {
|
||||
throw new Error(`No user message found at index ${userMessageIndex}`);
|
||||
}
|
||||
await userMessage
|
||||
.getByText(new RegExp(escapeRegExp(userText)))
|
||||
.first()
|
||||
.hover();
|
||||
const trigger = userMessage.getByTestId("rewind-menu-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
|
||||
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
|
||||
await expect(modeItem).toBeVisible({ timeout: 10_000 });
|
||||
await modeItem.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
|
||||
if (mode !== "files") {
|
||||
await waitForNextTimelineEpoch(handle, beforeEpoch);
|
||||
}
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertFileExists(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
|
||||
}
|
||||
|
||||
export async function assertFileMissing(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
|
||||
}
|
||||
|
||||
export async function assertFileContains(filePath: string, text: string): Promise<void> {
|
||||
await assertFileExists(filePath);
|
||||
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
|
||||
await expectComposerEditable(handle.page);
|
||||
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupRewindFlow(input: {
|
||||
handle?: AgentHandle;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
if (input.handle) {
|
||||
await closeAgent(input.handle);
|
||||
}
|
||||
rmSync(input.cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
|
||||
const client = handle.client as TerminalPerfDaemonClient & {
|
||||
fetchAgentTimeline: (
|
||||
agentId: string,
|
||||
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
|
||||
) => Promise<{ epoch?: string }>;
|
||||
};
|
||||
const timeline = await client.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
limit: 0,
|
||||
});
|
||||
return timeline.epoch;
|
||||
}
|
||||
|
||||
async function waitForNextTimelineEpoch(
|
||||
handle: AgentHandle,
|
||||
beforeEpoch: string | undefined,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
|
||||
.not.toBe(beforeEpoch);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
@@ -35,6 +36,10 @@ export interface TerminalPerfDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
@@ -68,6 +73,7 @@ interface TerminalPerfDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
appVersion?: string;
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
@@ -91,12 +97,22 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `terminal-perf-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: loadAppVersion(),
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||
const serverId = getServerId();
|
||||
const route = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "claude",
|
||||
rewindMode: "both",
|
||||
fileReverted: true,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "codex",
|
||||
rewindMode: "conversation",
|
||||
fileReverted: false,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "opencode",
|
||||
initialRewindMode: "both",
|
||||
rewindMode: "both",
|
||||
fileReverted: true,
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineRewindFlowSpec } from "./rewind-flow.shared";
|
||||
|
||||
defineRewindFlowSpec({
|
||||
provider: "pi",
|
||||
rewindMode: "conversation",
|
||||
fileReverted: false,
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
assertChatTranscript,
|
||||
assertComposerIdle,
|
||||
assertFileContains,
|
||||
assertFileExists,
|
||||
assertFileMissing,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
rewindMessage,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
type RewindFlowMode,
|
||||
type RewindFlowProvider,
|
||||
} from "./helpers/rewind-flow";
|
||||
|
||||
const FILE_PROMPT = "Use the Write tool to create ./qa.txt with the exact content: PASEO_QA_TOKEN";
|
||||
|
||||
interface RewindFlowCase {
|
||||
provider: RewindFlowProvider;
|
||||
initialRewindMode?: RewindFlowMode;
|
||||
rewindMode: RewindFlowMode;
|
||||
fileReverted: boolean;
|
||||
}
|
||||
|
||||
export function defineRewindFlowSpec(input: RewindFlowCase): void {
|
||||
test.describe(`rewind flow - ${input.provider}`, () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("rewinds conversation and file-write turns without transcript drift", async ({ page }) => {
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-rewind-flow-${input.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
});
|
||||
|
||||
await sendMessage(handle, "hello");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await rewindMessage(handle, 0, input.initialRewindMode ?? "conversation");
|
||||
await assertChatTranscript(handle, []);
|
||||
await assertComposerIdle(handle);
|
||||
|
||||
await sendMessage(handle, "hello");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await sendMessage(handle, FILE_PROMPT);
|
||||
await assertFileContains(path.join(cwd, "qa.txt"), "PASEO_QA_TOKEN");
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
{ role: "user", text: /Use the Write tool/ },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
|
||||
await rewindMessage(handle, 1, input.rewindMode);
|
||||
if (input.fileReverted) {
|
||||
await assertFileMissing(path.join(cwd, "qa.txt"));
|
||||
} else {
|
||||
await assertFileExists(path.join(cwd, "qa.txt"));
|
||||
}
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: "hello" },
|
||||
{ role: "assistant", text: /.+/ },
|
||||
]);
|
||||
await assertComposerIdle(handle);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "./helpers/composer";
|
||||
|
||||
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected);
|
||||
}
|
||||
|
||||
test.describe("Rewind sheet", () => {
|
||||
test("rewinds from a user message sheet option", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for first rewind turn.";
|
||||
const secondPrompt = "Prepare deleted rewind turn assistant content.";
|
||||
const replacementPrompt = "emit 1 coalesced agent stream updates for replacement rewind turn.";
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 1);
|
||||
await submitMessage(page, secondPrompt);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toBeVisible();
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 1);
|
||||
await expectComposerDraft(page, firstPrompt);
|
||||
|
||||
await submitMessage(page, replacementPrompt);
|
||||
await expect(page.getByText(replacementPrompt, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(secondPrompt, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Cycle 1", { exact: true })).toHaveCount(0);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, "");
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
const preservedDraft = "Keep this human draft after rewind.";
|
||||
await fillComposerDraft(page, preservedDraft);
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-files").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, preservedDraft);
|
||||
await expectUserMessageCount(page, 2);
|
||||
|
||||
await fillComposerDraft(page, "");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await page.getByText(replacementPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").last().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-both").click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toHaveCount(0);
|
||||
await expectComposerDraft(page, replacementPrompt);
|
||||
await expectUserMessageCount(page, 1);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("surfaces rewind failures without crashing the page", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("rewind-failure-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const firstPrompt = "emit 1 coalesced agent stream updates for failed rewind turn.";
|
||||
const rewindError = "No file checkpoint found for message rewind-failure-e2e.";
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Rewind failure e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
featureValues: {
|
||||
mockRewindError: rewindError,
|
||||
},
|
||||
initialPrompt: firstPrompt,
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await expect(page.getByText(firstPrompt, { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
const rewindSheet = page.getByTestId("rewind-menu-content");
|
||||
await expect(rewindSheet).toBeVisible();
|
||||
await expect(rewindSheet.getByText("Rewind to this message", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
rewindSheet.getByText("This action cannot be undone", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("rewind-menu-conversation").click();
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(rewindError);
|
||||
await expect(page.getByText("Uncaught Error")).toHaveCount(0);
|
||||
|
||||
await page.getByText(firstPrompt, { exact: true }).hover();
|
||||
await page.getByTestId("rewind-menu-trigger").first().click();
|
||||
await expect(page.getByTestId("rewind-menu-content")).toBeVisible();
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerEditable,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
submitMessage,
|
||||
} from "./helpers/composer";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function expectUserMessageCount(page: Page, expected: number): Promise<void> {
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(expected, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function expectIdleComposer(page: Page): Promise<void> {
|
||||
await expectComposerEditable(page);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoLoadingRegressionAfterIdle(page: Page): Promise<void> {
|
||||
await expectIdleComposer(page);
|
||||
await page.waitForTimeout(1_000);
|
||||
await expectIdleComposer(page);
|
||||
}
|
||||
|
||||
test.describe("User message UI contract", () => {
|
||||
test("dedupes mock provider user_message echoes across multi-turn sends", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("user-message-contract-e2e-");
|
||||
const client = await connectTerminalClient();
|
||||
const prompts = [
|
||||
"emit 1 coalesced agent stream updates for user message contract turn one.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn two.",
|
||||
"emit 1 coalesced agent stream updates for user message contract turn three.",
|
||||
];
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "User message contract e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await openAgent(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
for (let index = 0; index < prompts.length; index += 1) {
|
||||
const prompt = prompts[index]!;
|
||||
await submitMessage(page, prompt);
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("stress-update-0", { exact: true }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expectUserMessageCount(page, index + 1);
|
||||
await expectNoLoadingRegressionAfterIdle(page);
|
||||
}
|
||||
|
||||
await fillComposerDraft(page, "append");
|
||||
await composerLocator(page).evaluate((element) => element.blur());
|
||||
await expectUserMessageCount(page, 3);
|
||||
await expectIdleComposer(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -412,16 +412,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
return (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
messageId={item.id}
|
||||
message={item.text}
|
||||
images={item.images}
|
||||
attachments={item.attachments}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
capabilities={agent.capabilities}
|
||||
client={client}
|
||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[],
|
||||
[agent.capabilities, agentId, client, resolvedServerId],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
|
||||
@@ -353,6 +353,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
expect(userMessage.images).toEqual([image]);
|
||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||
expect(userMessage.id).toBe(call.options.messageId);
|
||||
expect(userMessage.optimistic).toBe(true);
|
||||
});
|
||||
|
||||
it("appends to the existing head when one is present", async () => {
|
||||
|
||||
@@ -135,6 +135,7 @@ export async function dispatchComposerAgentMessage(
|
||||
id: messageId,
|
||||
text: input.text,
|
||||
timestamp: new Date(),
|
||||
optimistic: true,
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
|
||||
@@ -102,15 +102,23 @@ import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-ur
|
||||
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface UserMessageProps {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
message: string;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: AgentAttachment[];
|
||||
timestamp: number;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
client?: DaemonClient | null;
|
||||
isFirstInGroup?: boolean;
|
||||
isLastInGroup?: boolean;
|
||||
disableOuterSpacing?: boolean;
|
||||
@@ -450,10 +458,15 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
||||
}
|
||||
|
||||
export const UserMessage = memo(function UserMessage({
|
||||
serverId,
|
||||
agentId,
|
||||
messageId,
|
||||
message,
|
||||
images = [],
|
||||
attachments = [],
|
||||
timestamp,
|
||||
capabilities,
|
||||
client,
|
||||
isFirstInGroup = true,
|
||||
isLastInGroup = true,
|
||||
disableOuterSpacing,
|
||||
@@ -469,10 +482,17 @@ export const UserMessage = memo(function UserMessage({
|
||||
() => formatMessageTimestamp(new Date(timestamp)),
|
||||
[timestamp],
|
||||
);
|
||||
const rewindMutation = useRewindAgentMutation({ serverId, agentId, client, messageId });
|
||||
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const getMessageContent = useCallback(() => message, [message]);
|
||||
const handleRewind = useCallback(
|
||||
(input: { mode: RewindMode; rewoundText: string }) => {
|
||||
return rewindMutation.rewindAgent(input);
|
||||
},
|
||||
[rewindMutation],
|
||||
);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
@@ -510,7 +530,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={containerStyle}>
|
||||
<View style={containerStyle} testID="user-message">
|
||||
<View
|
||||
style={userMessageStylesheet.content}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
@@ -549,6 +569,14 @@ export const UserMessage = memo(function UserMessage({
|
||||
{hasText ? (
|
||||
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
|
||||
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
|
||||
{capabilities ? (
|
||||
<RewindMenu
|
||||
capabilities={capabilities}
|
||||
isPending={rewindMutation.isPending}
|
||||
rewoundText={message}
|
||||
onRewind={handleRewind}
|
||||
/>
|
||||
) : null}
|
||||
<TurnCopyButton
|
||||
getContent={getMessageContent}
|
||||
containerStyle={userMessageStylesheet.copyButton}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { restoreComposerTextIfEmpty } from "./composer-restore";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
|
||||
describe("restoreComposerTextIfEmpty", () => {
|
||||
test("restores the rewound message when the composer is empty", () => {
|
||||
expect(
|
||||
restoreComposerTextIfEmpty({
|
||||
currentText: "",
|
||||
rewoundText: "message before rewind",
|
||||
}),
|
||||
).toBe("message before rewind");
|
||||
});
|
||||
|
||||
test("preserves an existing composer draft", () => {
|
||||
expect(
|
||||
restoreComposerTextIfEmpty({
|
||||
currentText: "keep this draft",
|
||||
rewoundText: "message before rewind",
|
||||
}),
|
||||
).toBe("keep this draft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRestoreComposerForRewindMode", () => {
|
||||
test("restores only conversation-mutating rewind modes", () => {
|
||||
expect(shouldRestoreComposerForRewindMode("conversation")).toBe(true);
|
||||
expect(shouldRestoreComposerForRewindMode("files")).toBe(false);
|
||||
expect(shouldRestoreComposerForRewindMode("both")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface RewindComposerRestoreContextValue {
|
||||
restoreTextIfComposerEmpty: (text: string) => void;
|
||||
}
|
||||
|
||||
interface RewindComposerRestoreProviderProps {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const RewindComposerRestoreContext = createContext<RewindComposerRestoreContextValue | null>(null);
|
||||
|
||||
export function restoreComposerTextIfEmpty(input: {
|
||||
currentText: string;
|
||||
rewoundText: string;
|
||||
}): string {
|
||||
if (input.currentText.length > 0) {
|
||||
return input.currentText;
|
||||
}
|
||||
return input.rewoundText;
|
||||
}
|
||||
|
||||
export function RewindComposerRestoreProvider({
|
||||
text,
|
||||
setText,
|
||||
children,
|
||||
}: RewindComposerRestoreProviderProps) {
|
||||
const textRef = useRef(text);
|
||||
|
||||
useEffect(() => {
|
||||
textRef.current = text;
|
||||
}, [text]);
|
||||
|
||||
const restoreTextIfComposerEmpty = useCallback(
|
||||
(rewoundText: string) => {
|
||||
const nextText = restoreComposerTextIfEmpty({
|
||||
currentText: textRef.current,
|
||||
rewoundText,
|
||||
});
|
||||
if (nextText !== textRef.current) {
|
||||
setText(nextText);
|
||||
}
|
||||
},
|
||||
[setText],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ restoreTextIfComposerEmpty }), [restoreTextIfComposerEmpty]);
|
||||
|
||||
return (
|
||||
<RewindComposerRestoreContext.Provider value={value}>
|
||||
{children}
|
||||
</RewindComposerRestoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRewindComposerRestore(): RewindComposerRestoreContextValue | null {
|
||||
return useContext(RewindComposerRestoreContext);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { memo, useCallback, useMemo, useState, type ComponentType } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { FileText, Layers, MessageSquare, Undo2 } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
type RewindMenuItem,
|
||||
type RewindMode,
|
||||
useRewindCapabilities,
|
||||
} from "./use-rewind-capabilities";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type { RewindMode };
|
||||
|
||||
interface RewindMenuProps {
|
||||
capabilities: AgentCapabilityFlags;
|
||||
rewoundText: string;
|
||||
onRewind: (input: { mode: RewindMode; rewoundText: string }) => Promise<void> | void;
|
||||
isPending?: boolean;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const REWIND_HEADER: SheetHeader = { title: "Rewind to this message" };
|
||||
const SNAP_POINTS: string[] = ["42%", "72%"];
|
||||
|
||||
function iconForItem(item: RewindMenuItem): ComponentType<{ color: string; size: number }> {
|
||||
switch (item.mode) {
|
||||
case "conversation":
|
||||
return MessageSquare;
|
||||
case "files":
|
||||
return FileText;
|
||||
case "both":
|
||||
return Layers;
|
||||
}
|
||||
}
|
||||
|
||||
export const RewindMenu = memo(function RewindMenu({
|
||||
capabilities,
|
||||
rewoundText,
|
||||
onRewind,
|
||||
isPending = false,
|
||||
testID = "rewind-menu",
|
||||
}: RewindMenuProps) {
|
||||
const items = useRewindCapabilities(capabilities);
|
||||
const [isSheetVisible, setIsSheetVisible] = useState(false);
|
||||
const [pendingMode, setPendingMode] = useState<RewindMode | null>(null);
|
||||
const handleOpen = useCallback(() => setIsSheetVisible(true), []);
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isPending) {
|
||||
setIsSheetVisible(false);
|
||||
}
|
||||
}, [isPending]);
|
||||
const handleSelect = useCallback(
|
||||
(mode: RewindMode) => async () => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
setPendingMode(mode);
|
||||
try {
|
||||
await onRewind({ mode, rewoundText });
|
||||
} catch {
|
||||
// useRewindAgentMutation owns the toast; the sheet only owns flow state.
|
||||
} finally {
|
||||
setPendingMode(null);
|
||||
setIsSheetVisible(false);
|
||||
}
|
||||
},
|
||||
[isPending, onRewind, rewoundText],
|
||||
);
|
||||
const triggerIcon = useCallback((color: string) => <Undo2 size={16} color={color} />, []);
|
||||
const tooltipContent = useMemo(
|
||||
() => (
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>Rewind to this message</Text>
|
||||
</TooltipContent>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
accessibilityLabel="Rewind to this message"
|
||||
disabled={isPending}
|
||||
leftIcon={triggerIcon}
|
||||
onPress={handleOpen}
|
||||
size="xs"
|
||||
style={styles.trigger}
|
||||
testID={`${testID}-trigger`}
|
||||
variant="ghost"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent}
|
||||
</Tooltip>
|
||||
<AdaptiveModalSheet
|
||||
desktopMaxWidth={420}
|
||||
header={REWIND_HEADER}
|
||||
onClose={handleClose}
|
||||
scrollable={false}
|
||||
snapPoints={SNAP_POINTS}
|
||||
testID={`${testID}-content`}
|
||||
visible={isSheetVisible}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.warningText}>This action cannot be undone</Text>
|
||||
<View style={styles.actions}>
|
||||
{items.map((item) => (
|
||||
<Button
|
||||
key={item.mode}
|
||||
leftIcon={iconForItem(item)}
|
||||
loading={pendingMode === item.mode}
|
||||
disabled={isPending}
|
||||
onPress={handleSelect(item.mode)}
|
||||
size="md"
|
||||
style={styles.actionButton}
|
||||
testID={item.testID}
|
||||
variant="secondary"
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
minHeight: 24,
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
},
|
||||
content: {
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
warningText: {
|
||||
color: theme.colors.statusWarning,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { RewindMode } from "./use-rewind-capabilities";
|
||||
|
||||
export function shouldRestoreComposerForRewindMode(mode: RewindMode): boolean {
|
||||
return mode === "conversation" || mode === "both";
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { RewindMode } from "./use-rewind-capabilities";
|
||||
import { useRewindComposerRestore } from "./composer-restore";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
import { clearOptimisticUserMessages } from "@/types/stream";
|
||||
|
||||
interface UseRewindAgentMutationInput {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
messageId?: string;
|
||||
client?: DaemonClient | null;
|
||||
}
|
||||
|
||||
interface RewindAgentInput {
|
||||
mode: RewindMode;
|
||||
rewoundText: string;
|
||||
}
|
||||
|
||||
export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
rewindAgent: (input: RewindAgentInput) => Promise<void>;
|
||||
isPending: boolean;
|
||||
} {
|
||||
const toast = useToast();
|
||||
const composerRestore = useRewindComposerRestore();
|
||||
const { isPending, mutateAsync } = useMutation({
|
||||
mutationFn: async ({ mode }: RewindAgentInput) => {
|
||||
if (!input.client || !input.agentId || !input.messageId) {
|
||||
throw new Error("Daemon client not available");
|
||||
}
|
||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||
if (mode !== "files") {
|
||||
if (input.serverId) {
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
useSessionStore.getState().setAgentStreamState(input.serverId, input.agentId, {
|
||||
tail: clearOptimisticUserMessages(session?.agentStreamTail.get(input.agentId) ?? []),
|
||||
head: clearOptimisticUserMessages(session?.agentStreamHead.get(input.agentId) ?? []),
|
||||
});
|
||||
}
|
||||
const cursor = input.serverId
|
||||
? useSessionStore
|
||||
.getState()
|
||||
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
|
||||
: undefined;
|
||||
await input.client.fetchAgentTimeline(input.agentId, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, variables) => {
|
||||
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
|
||||
return;
|
||||
}
|
||||
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to rewind agent");
|
||||
},
|
||||
});
|
||||
|
||||
const rewindAgent = useCallback(
|
||||
async (rewindInput: RewindAgentInput) => {
|
||||
if (isPending) {
|
||||
return;
|
||||
}
|
||||
await mutateAsync(rewindInput);
|
||||
},
|
||||
[isPending, mutateAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
rewindAgent,
|
||||
isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { resolveRewindMenuItems } from "./use-rewind-capabilities";
|
||||
|
||||
describe("resolveRewindMenuItems", () => {
|
||||
test("returns no items when the provider declares no rewind capability", () => {
|
||||
expect(
|
||||
resolveRewindMenuItems({
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns only the capabilities declared by the provider", () => {
|
||||
expect(
|
||||
resolveRewindMenuItems({
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: true,
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
mode: "conversation",
|
||||
label: "Rewind conversation",
|
||||
testID: "rewind-menu-conversation",
|
||||
},
|
||||
{
|
||||
mode: "both",
|
||||
label: "Rewind conversation and files",
|
||||
testID: "rewind-menu-both",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export type RewindMode = "conversation" | "files" | "both";
|
||||
|
||||
export interface RewindMenuItem {
|
||||
mode: RewindMode;
|
||||
label: string;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
export function resolveRewindMenuItems(
|
||||
capabilities:
|
||||
| Pick<
|
||||
AgentCapabilityFlags,
|
||||
"supportsRewindConversation" | "supportsRewindFiles" | "supportsRewindBoth"
|
||||
>
|
||||
| null
|
||||
| undefined,
|
||||
): RewindMenuItem[] {
|
||||
if (!capabilities) {
|
||||
return [];
|
||||
}
|
||||
const items: RewindMenuItem[] = [];
|
||||
if (capabilities.supportsRewindConversation) {
|
||||
items.push({
|
||||
mode: "conversation",
|
||||
label: "Rewind conversation",
|
||||
testID: "rewind-menu-conversation",
|
||||
});
|
||||
}
|
||||
if (capabilities.supportsRewindFiles) {
|
||||
items.push({
|
||||
mode: "files",
|
||||
label: "Rewind files",
|
||||
testID: "rewind-menu-files",
|
||||
});
|
||||
}
|
||||
if (capabilities.supportsRewindBoth) {
|
||||
items.push({
|
||||
mode: "both",
|
||||
label: "Rewind conversation and files",
|
||||
testID: "rewind-menu-both",
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function useRewindCapabilities(
|
||||
capabilities: Parameters<typeof resolveRewindMenuItems>[0],
|
||||
): RewindMenuItem[] {
|
||||
return useMemo(() => resolveRewindMenuItems(capabilities), [capabilities]);
|
||||
}
|
||||
@@ -1664,6 +1664,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
id: messageId,
|
||||
text: message,
|
||||
timestamp: new Date(),
|
||||
optimistic: true,
|
||||
...(images && images.length > 0 ? { images } : {}),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
};
|
||||
|
||||
// Append to head if streaming (keeps the user message with the current
|
||||
|
||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
commandDraftConfig: DraftCommandConfig | undefined;
|
||||
};
|
||||
|
||||
interface AgentInputDraft {
|
||||
export interface AgentInputDraft {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
attachments: UserComposerAttachment[];
|
||||
|
||||
@@ -99,6 +99,16 @@ function expectSyncErrorSync(state: ReadyState): void {
|
||||
expect(state.sync.status).toBe("sync_error");
|
||||
}
|
||||
|
||||
function stateMachineLabel(state: AgentScreenViewState): "loading" | "resolving" | Agent["status"] {
|
||||
if (state.tag === "boot") {
|
||||
return state.reason;
|
||||
}
|
||||
if (state.tag === "ready") {
|
||||
return state.agent.status;
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
describe("deriveAgentScreenViewState", () => {
|
||||
it("returns boot loading before first interactive paint", () => {
|
||||
const memory = createBaseMemory();
|
||||
@@ -383,6 +393,39 @@ describe("deriveAgentScreenViewState", () => {
|
||||
expect(ready.agent.status).toBe("running");
|
||||
});
|
||||
|
||||
it("keeps send lifecycle transitions forward-only across optimistic and authoritative handoff", () => {
|
||||
let memory = createBaseMemory();
|
||||
const transitions: Array<"loading" | "resolving" | Agent["status"]> = [];
|
||||
|
||||
for (const input of [
|
||||
createBaseInput(),
|
||||
{
|
||||
...createBaseInput(),
|
||||
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
|
||||
placeholderAgent: createAgent("agent-1"),
|
||||
shouldUseOptimisticStream: true,
|
||||
},
|
||||
{
|
||||
...createBaseInput(),
|
||||
agent: createAgentWithStatus({ id: "agent-1", status: "running" }),
|
||||
placeholderAgent: createAgent("agent-1"),
|
||||
shouldUseOptimisticStream: true,
|
||||
},
|
||||
{
|
||||
...createBaseInput(),
|
||||
agent: createAgentWithStatus({ id: "agent-1", status: "idle" }),
|
||||
},
|
||||
] satisfies AgentScreenMachineInput[]) {
|
||||
const result = deriveAgentScreenViewState({ input, memory });
|
||||
memory = result.memory;
|
||||
transitions.push(stateMachineLabel(result.state));
|
||||
}
|
||||
|
||||
expect(transitions).toEqual(["loading", "running", "running", "idle"]);
|
||||
expect(transitions.join(" -> ")).not.toContain("running -> loading");
|
||||
expect(transitions.join(" -> ")).not.toContain("loading -> idle");
|
||||
});
|
||||
|
||||
it("keeps optimistic running status while authoritative agent is initializing", () => {
|
||||
const memory = createBaseMemory();
|
||||
const input: AgentScreenMachineInput = {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useRef } from "react";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export interface AgentScreenAgent {
|
||||
serverId: string;
|
||||
id: string;
|
||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
cwd: string;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
lastError?: string | null;
|
||||
projectPlacement?: {
|
||||
checkout?: {
|
||||
|
||||
@@ -133,6 +133,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
id: machine.attempt.clientMessageId,
|
||||
text: machine.attempt.text,
|
||||
timestamp: machine.attempt.timestamp,
|
||||
optimistic: true,
|
||||
...(machine.attempt.images && machine.attempt.images.length > 0
|
||||
? { images: machine.attempt.images }
|
||||
: {}),
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
@@ -23,7 +24,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useAgentInputDraft, type AgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import {
|
||||
type AgentScreenAgent,
|
||||
type AgentScreenMissingState,
|
||||
@@ -69,6 +70,7 @@ interface ChatAgentStateShape {
|
||||
id: string | null;
|
||||
status: Agent["status"] | null;
|
||||
cwd: string | null;
|
||||
capabilities?: Agent["capabilities"];
|
||||
lastError?: Agent["lastError"] | null;
|
||||
}
|
||||
|
||||
@@ -111,6 +113,7 @@ function selectChatAgentState(
|
||||
id: agent.id,
|
||||
status: agent.status,
|
||||
cwd: agent.cwd,
|
||||
capabilities: agent.capabilities,
|
||||
lastError: agent.lastError ?? null,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
@@ -130,6 +133,7 @@ function buildChatAgentFromState(
|
||||
id: state.id,
|
||||
status: state.status,
|
||||
cwd: state.cwd,
|
||||
capabilities: state.capabilities,
|
||||
lastError: state.lastError ?? null,
|
||||
projectPlacement,
|
||||
};
|
||||
@@ -945,70 +949,162 @@ function ChatAgentContent({
|
||||
effectiveAgent,
|
||||
});
|
||||
if (nonReadyView) return nonReadyView;
|
||||
invariant(agentId, "agent id is defined when agent content is ready");
|
||||
invariant(effectiveAgent, "effectiveAgent is defined when the non-ready view is absent");
|
||||
invariant(agentState.cwd, "agent cwd is defined when agent content is ready");
|
||||
const agentCwd = agentState.cwd;
|
||||
invariant(agentCwd, "agent cwd is defined when agent content is ready");
|
||||
const showHistorySyncOverlay =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.ui === "overlay";
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
<ReanimatedAnimated.View style={animatedContentStyle}>
|
||||
<AgentStreamSection
|
||||
streamViewRef={streamViewRef}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
agent={effectiveAgent}
|
||||
pendingCreate={pendingCreate}
|
||||
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
||||
canFinalizePendingCreate={canFinalizePendingCreate}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
toast={panelToast.api}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
<ChatAgentReadyContent
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||
agentState={agentState}
|
||||
effectiveAgent={effectiveAgent}
|
||||
pendingCreate={pendingCreate}
|
||||
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
||||
canFinalizePendingCreate={canFinalizePendingCreate}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
panelToast={panelToast}
|
||||
streamViewRef={streamViewRef}
|
||||
animatedContentStyle={animatedContentStyle}
|
||||
handleFilesDropped={handleFilesDropped}
|
||||
handleAddImagesCallback={handleAddImagesCallback}
|
||||
handleComposerHeightChange={handleComposerHeightChange}
|
||||
handleMessageSent={handleMessageSent}
|
||||
showPendingCreateSubmitLoading={showPendingCreateSubmitLoading}
|
||||
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||
cwd={agentCwd}
|
||||
attentionController={attentionController}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||
archivedAt={agentState.archivedAt}
|
||||
cwd={agentState.cwd}
|
||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onComposerHeightChange={handleComposerHeightChange}
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
function ChatAgentReadyContent({
|
||||
serverId,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
isArchivingCurrentAgent,
|
||||
agentState,
|
||||
effectiveAgent,
|
||||
pendingCreate,
|
||||
shouldUseOptimisticStream,
|
||||
canFinalizePendingCreate,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
panelToast,
|
||||
streamViewRef,
|
||||
animatedContentStyle,
|
||||
handleFilesDropped,
|
||||
handleAddImagesCallback,
|
||||
handleComposerHeightChange,
|
||||
handleMessageSent,
|
||||
showPendingCreateSubmitLoading,
|
||||
showHistorySyncOverlay,
|
||||
cwd,
|
||||
attentionController,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
isPaneFocused: boolean;
|
||||
isArchivingCurrentAgent: boolean;
|
||||
agentState: ChatAgentSelectedState;
|
||||
effectiveAgent: AgentScreenAgent;
|
||||
pendingCreate: PendingCreateAttempt | null;
|
||||
shouldUseOptimisticStream: boolean;
|
||||
canFinalizePendingCreate: boolean;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
panelToast: ReturnType<typeof useToastHost>;
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
animatedContentStyle: object[];
|
||||
handleFilesDropped: (files: ImageAttachment[]) => void;
|
||||
handleAddImagesCallback: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
handleComposerHeightChange: (height: number) => void;
|
||||
handleMessageSent: () => void;
|
||||
showPendingCreateSubmitLoading: boolean;
|
||||
showHistorySyncOverlay: boolean;
|
||||
cwd: string;
|
||||
attentionController: ReturnType<typeof useAgentAttentionClear>;
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId,
|
||||
}),
|
||||
});
|
||||
|
||||
{viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
viewState.sync.ui === "overlay" ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
return (
|
||||
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||
<View style={styles.root}>
|
||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.contentContainer}>
|
||||
<ReanimatedAnimated.View style={animatedContentStyle}>
|
||||
<AgentStreamSection
|
||||
streamViewRef={streamViewRef}
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
agent={effectiveAgent}
|
||||
pendingCreate={pendingCreate}
|
||||
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
||||
canFinalizePendingCreate={canFinalizePendingCreate}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
toast={panelToast.api}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ToastViewport
|
||||
toast={panelToast.toast}
|
||||
onDismiss={panelToast.dismiss}
|
||||
placement="panel"
|
||||
/>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
<AgentComposerSection
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||
archivedAt={agentState.archivedAt}
|
||||
cwd={cwd}
|
||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||
agentInputDraft={agentInputDraft}
|
||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
onComposerHeightChange={handleComposerHeightChange}
|
||||
onMessageSent={handleMessageSent}
|
||||
/>
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
|
||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
||||
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{showHistorySyncOverlay ? (
|
||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ToastViewport
|
||||
toast={panelToast.toast}
|
||||
onDismiss={panelToast.dismiss}
|
||||
placement="panel"
|
||||
/>
|
||||
</View>
|
||||
</FileDropZone>
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
|
||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
||||
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</RewindComposerRestoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1081,6 +1177,7 @@ function AgentStreamSection({
|
||||
id: pendingCreate.clientMessageId,
|
||||
text: pendingCreate.text,
|
||||
timestamp: new Date(pendingCreate.timestamp),
|
||||
optimistic: true,
|
||||
...(pendingCreate.images && pendingCreate.images.length > 0
|
||||
? { images: pendingCreate.images }
|
||||
: {}),
|
||||
@@ -1183,6 +1280,7 @@ function AgentComposerSection({
|
||||
archivedAt,
|
||||
cwd,
|
||||
isSubmitLoading,
|
||||
agentInputDraft,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onAddImages,
|
||||
@@ -1196,6 +1294,7 @@ function AgentComposerSection({
|
||||
archivedAt: Date | null;
|
||||
cwd: string;
|
||||
isSubmitLoading: boolean;
|
||||
agentInputDraft: AgentInputDraft;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
@@ -1219,6 +1318,7 @@ function AgentComposerSection({
|
||||
isPaneFocused={isPaneFocused}
|
||||
cwd={cwd}
|
||||
isSubmitLoading={isSubmitLoading}
|
||||
agentInputDraft={agentInputDraft}
|
||||
onAttentionInputFocus={onAttentionInputFocus}
|
||||
onAttentionPromptSend={onAttentionPromptSend}
|
||||
onAddImages={onAddImages}
|
||||
@@ -1234,6 +1334,7 @@ function ActiveAgentComposer({
|
||||
isPaneFocused,
|
||||
cwd,
|
||||
isSubmitLoading,
|
||||
agentInputDraft,
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
onAddImages,
|
||||
@@ -1245,6 +1346,7 @@ function ActiveAgentComposer({
|
||||
isPaneFocused: boolean;
|
||||
cwd: string;
|
||||
isSubmitLoading: boolean;
|
||||
agentInputDraft: AgentInputDraft;
|
||||
onAttentionInputFocus: () => void;
|
||||
onAttentionPromptSend: () => void;
|
||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||
@@ -1270,12 +1372,6 @@ function ActiveAgentComposer({
|
||||
[serverId],
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId,
|
||||
}),
|
||||
});
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd,
|
||||
|
||||
@@ -276,8 +276,17 @@ describe("processTimelineResponse", () => {
|
||||
});
|
||||
|
||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
||||
const existingTail: StreamItem[] = [
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "only-user",
|
||||
text: "first turn",
|
||||
timestamp: new Date(500),
|
||||
},
|
||||
];
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: existingTail,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
@@ -288,6 +297,8 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.tail).toEqual([]);
|
||||
expect(getUserTexts(result.tail)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("performs bootstrap tail init with catch-up side effect", () => {
|
||||
@@ -933,6 +944,34 @@ describe("processAgentStreamEvent", () => {
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("resets visible timeline when a new epoch starts at seq 1", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
const currentTail = [makeAssistantItem("old timeline")];
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("rewound start", "user_message"),
|
||||
seq: 1,
|
||||
epoch: "epoch-2",
|
||||
currentCursor: existingCursor,
|
||||
currentTail,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-2",
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
});
|
||||
expect(getAssistantTexts(result.tail)).toEqual([]);
|
||||
expect(getUserTexts(result.tail)).toEqual(["rewound start"]);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("initializes cursor when none exists", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
|
||||
@@ -623,6 +623,14 @@ export interface AgentStreamReducerEvent {
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
interface TimelineSequencingGateResult {
|
||||
shouldApplyStreamEvent: boolean;
|
||||
nextTimelineCursor: TimelineCursor | null;
|
||||
cursorChanged: boolean;
|
||||
resetLiveTimeline: boolean;
|
||||
sideEffects: AgentStreamReducerSideEffect[];
|
||||
}
|
||||
|
||||
export interface AgentStreamReducerAgentSnapshot {
|
||||
status: AgentLifecycleStatus;
|
||||
updatedAt: Date;
|
||||
@@ -672,61 +680,91 @@ function applyAgentPatch(
|
||||
};
|
||||
}
|
||||
|
||||
function processTimelineSequencingGate(input: {
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
}): TimelineSequencingGateResult {
|
||||
const { event, seq, epoch, currentCursor } = input;
|
||||
const base: TimelineSequencingGateResult = {
|
||||
shouldApplyStreamEvent: true,
|
||||
nextTimelineCursor: null,
|
||||
cursorChanged: false,
|
||||
resetLiveTimeline: false,
|
||||
sideEffects: [],
|
||||
};
|
||||
if (event.type !== "timeline" || typeof seq !== "number" || typeof epoch !== "string") {
|
||||
return base;
|
||||
}
|
||||
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
epoch,
|
||||
seq,
|
||||
});
|
||||
|
||||
if (decision === "init") {
|
||||
return {
|
||||
...base,
|
||||
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
|
||||
cursorChanged: true,
|
||||
};
|
||||
}
|
||||
if (decision === "accept") {
|
||||
return {
|
||||
...base,
|
||||
nextTimelineCursor: {
|
||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
||||
epoch,
|
||||
endSeq: seq,
|
||||
},
|
||||
cursorChanged: true,
|
||||
};
|
||||
}
|
||||
if (decision === "gap") {
|
||||
return {
|
||||
...base,
|
||||
shouldApplyStreamEvent: false,
|
||||
sideEffects: currentCursor
|
||||
? [
|
||||
{
|
||||
type: "catch_up",
|
||||
cursor: { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
if (decision === "drop_epoch" && seq === 1) {
|
||||
return {
|
||||
...base,
|
||||
nextTimelineCursor: { epoch, startSeq: seq, endSeq: seq },
|
||||
cursorChanged: true,
|
||||
resetLiveTimeline: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
shouldApplyStreamEvent: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function processAgentStreamEvent(
|
||||
input: ProcessAgentStreamEventInput,
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
||||
input;
|
||||
|
||||
let shouldApplyStreamEvent = true;
|
||||
let nextTimelineCursor: TimelineCursor | null = null;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Timeline sequencing gate
|
||||
// ------------------------------------------------------------------
|
||||
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
epoch,
|
||||
seq,
|
||||
});
|
||||
|
||||
if (decision === "init") {
|
||||
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
|
||||
cursorChanged = true;
|
||||
} else if (decision === "accept") {
|
||||
nextTimelineCursor = {
|
||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
||||
epoch,
|
||||
endSeq: seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else if (decision === "gap") {
|
||||
shouldApplyStreamEvent = false;
|
||||
if (currentCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: {
|
||||
epoch: currentCursor.epoch,
|
||||
endSeq: currentCursor.endSeq,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// drop_stale or drop_epoch
|
||||
shouldApplyStreamEvent = false;
|
||||
}
|
||||
}
|
||||
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
// ------------------------------------------------------------------
|
||||
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
|
||||
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
|
||||
? applyStreamEvent({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
tail: sequencing.resetLiveTimeline ? [] : currentTail,
|
||||
head: sequencing.resetLiveTimeline ? [] : currentHead,
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
@@ -771,11 +809,11 @@ export function processAgentStreamEvent(
|
||||
head,
|
||||
changedTail,
|
||||
changedHead,
|
||||
cursor: nextTimelineCursor,
|
||||
cursorChanged,
|
||||
cursor: sequencing.nextTimelineCursor,
|
||||
cursorChanged: sequencing.cursorChanged,
|
||||
agent: agentPatch,
|
||||
agentChanged,
|
||||
sideEffects,
|
||||
sideEffects: sequencing.sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface UserMessageItem {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
optimistic?: true;
|
||||
images?: UserMessageImageAttachment[];
|
||||
attachments?: AgentAttachment[];
|
||||
}
|
||||
@@ -184,10 +185,32 @@ function markThoughtReady(item: ThoughtItem): ThoughtItem {
|
||||
};
|
||||
}
|
||||
|
||||
function buildUserMessageItem(input: {
|
||||
id: string;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
optimistic?: UserMessageItem | null;
|
||||
}): UserMessageItem {
|
||||
const preservedImages = input.optimistic?.images;
|
||||
const preservedAttachments = input.optimistic?.attachments;
|
||||
|
||||
return {
|
||||
kind: "user_message",
|
||||
id: input.id,
|
||||
text: input.text,
|
||||
timestamp: input.timestamp,
|
||||
...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}),
|
||||
...(preservedAttachments && preservedAttachments.length > 0
|
||||
? { attachments: preservedAttachments }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function appendUserMessage(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
messageId?: string,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
@@ -197,32 +220,33 @@ function appendUserMessage(
|
||||
|
||||
const chunkSeed = chunk.trim() || chunk;
|
||||
const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
|
||||
const existingIndex = state.findIndex(
|
||||
(entry) => entry.kind === "user_message" && entry.id === entryId,
|
||||
);
|
||||
const existing =
|
||||
existingIndex >= 0 && state[existingIndex]?.kind === "user_message"
|
||||
? state[existingIndex]
|
||||
: null;
|
||||
const preservedImages = existing?.images;
|
||||
const optimisticIndex =
|
||||
source === "live"
|
||||
? state.findIndex((entry) => entry.kind === "user_message" && entry.optimistic)
|
||||
: -1;
|
||||
const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null;
|
||||
|
||||
const nextItem: UserMessageItem = {
|
||||
kind: "user_message",
|
||||
const nextItem = buildUserMessageItem({
|
||||
id: entryId,
|
||||
text: chunk,
|
||||
timestamp,
|
||||
...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}),
|
||||
};
|
||||
optimistic,
|
||||
});
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
if (optimisticIndex >= 0) {
|
||||
const next = [...state];
|
||||
next[existingIndex] = nextItem;
|
||||
next[optimisticIndex] = nextItem;
|
||||
return next;
|
||||
}
|
||||
|
||||
return [...state, nextItem];
|
||||
}
|
||||
|
||||
export function clearOptimisticUserMessages(state: StreamItem[]): StreamItem[] {
|
||||
const next = state.filter((item) => item.kind !== "user_message" || !item.optimistic);
|
||||
return next.length === state.length ? state : next;
|
||||
}
|
||||
|
||||
function appendAssistantMessage(
|
||||
state: StreamItem[],
|
||||
text: string,
|
||||
@@ -664,7 +688,9 @@ function reduceTimelineEvent(
|
||||
const item = event.item;
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
return finalizeActiveThoughts(appendUserMessage(state, item.text, timestamp, item.messageId));
|
||||
return finalizeActiveThoughts(
|
||||
appendUserMessage(state, item.text, timestamp, source, item.messageId),
|
||||
);
|
||||
case "assistant_message":
|
||||
return finalizeActiveThoughts(
|
||||
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
|
||||
|
||||
@@ -64,6 +64,7 @@ import type {
|
||||
ProviderDiagnosticResponseMessage,
|
||||
DaemonGetStatusResponse,
|
||||
DaemonGetPairingOfferResponse,
|
||||
AgentRewindResponseMessage,
|
||||
ListTerminalsResponse,
|
||||
CreateTerminalResponse,
|
||||
SubscribeTerminalResponse,
|
||||
@@ -2232,6 +2233,40 @@ export class DaemonClient {
|
||||
await this.sendAgentMessage(agentId, text, options);
|
||||
}
|
||||
|
||||
async rewindAgent(
|
||||
agentId: string,
|
||||
messageId: string,
|
||||
mode: "conversation" | "files" | "both",
|
||||
): Promise<AgentRewindResponseMessage["payload"]> {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "agent.rewind.request",
|
||||
requestId,
|
||||
agentId,
|
||||
messageId,
|
||||
mode,
|
||||
});
|
||||
const payload = await this.sendRequest({
|
||||
requestId,
|
||||
message,
|
||||
timeout: 15000,
|
||||
options: { skipQueue: true },
|
||||
select: (msg) => {
|
||||
if (msg.type !== "agent.rewind.response") {
|
||||
return null;
|
||||
}
|
||||
if (msg.payload.requestId !== requestId) {
|
||||
return null;
|
||||
}
|
||||
return msg.payload;
|
||||
},
|
||||
});
|
||||
if (!payload.ok) {
|
||||
throw new Error(payload.error ?? "Agent rewind failed");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async cancelAgent(agentId: string): Promise<void> {
|
||||
const requestId = this.createRequestId();
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
|
||||
@@ -2192,9 +2192,10 @@ test("getAgent does not expose committed history internals once manager owns the
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
manager.recordUserMessage(snapshot.id, "hello boundary", {
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "user_message",
|
||||
text: "hello boundary",
|
||||
messageId: "msg-boundary-1",
|
||||
emitState: false,
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
@@ -3442,7 +3443,7 @@ test("keeps updatedAt monotonic when user message and run start happen in the sa
|
||||
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
|
||||
try {
|
||||
manager.recordUserMessage(snapshot.id, "hello");
|
||||
await manager.appendTimelineItem(snapshot.id, { type: "user_message", text: "hello" });
|
||||
const afterMessage = manager.getAgent(snapshot.id);
|
||||
expect(afterMessage).toBeDefined();
|
||||
const messageUpdatedAt = afterMessage!.updatedAt.getTime();
|
||||
@@ -3464,38 +3465,6 @@ test("keeps updatedAt monotonic when user message and run start happen in the sa
|
||||
}
|
||||
});
|
||||
|
||||
test("recordUserMessage can skip emitting agent_state when run start will emit running", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000121",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const lifecycleUpdates: string[] = [];
|
||||
manager.subscribe((event) => {
|
||||
if (event.type !== "agent_state" || event.agent.id !== snapshot.id) {
|
||||
return;
|
||||
}
|
||||
lifecycleUpdates.push(event.agent.lifecycle);
|
||||
});
|
||||
lifecycleUpdates.length = 0;
|
||||
|
||||
manager.recordUserMessage(snapshot.id, "hello", { emitState: false });
|
||||
|
||||
expect(lifecycleUpdates).toEqual([]);
|
||||
});
|
||||
|
||||
test("runAgent assembles finalText from trailing assistant chunks", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
@@ -5088,99 +5057,6 @@ test("closeAgent persists one final closed snapshot", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("hydrateTimeline skips provider user_message items to prevent duplicates with recordUserMessage", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-dedup-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
// Session whose streamHistory yields user_message + assistant_message items.
|
||||
// This simulates Codex provider replaying its thread history on resume.
|
||||
class HistoryWithUserMessagesSession extends TestAgentSession {
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "user_message", text: "hello from user", messageId: "msg_client_1" },
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: "hi there" },
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "user_message", text: "second question", messageId: "msg_client_2" },
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: "second answer" },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryUserMessageClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
// Fresh session yields history with user messages (simulates Codex resume)
|
||||
return new HistoryWithUserMessagesSession(config);
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("Not used in this test");
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new HistoryUserMessageClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000200",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
// Simulate canonical user messages already recorded by recordUserMessage
|
||||
// (the path that session.ts takes when user sends a message)
|
||||
manager.recordUserMessage(snapshot.id, "hello from user", {
|
||||
messageId: "msg_client_1",
|
||||
});
|
||||
manager.recordUserMessage(snapshot.id, "second question", {
|
||||
messageId: "msg_client_2",
|
||||
});
|
||||
|
||||
const beforeHydrate = manager.getTimeline(snapshot.id);
|
||||
const userMessagesBefore = beforeHydrate.filter((item) => item.type === "user_message");
|
||||
expect(userMessagesBefore).toHaveLength(2);
|
||||
|
||||
// hydrateTimeline replays provider history which includes user_message
|
||||
// items. These should NOT create duplicate rows since recordUserMessage
|
||||
// already created canonical entries.
|
||||
await manager.hydrateTimelineFromProvider(snapshot.id);
|
||||
|
||||
const afterHydrate = manager.getTimeline(snapshot.id);
|
||||
const userMessagesAfter = afterHydrate.filter((item) => item.type === "user_message");
|
||||
|
||||
// Should still have exactly 2 user messages, not 4
|
||||
expect(userMessagesAfter).toHaveLength(2);
|
||||
|
||||
// Non-user_message items from history should still be replayed
|
||||
const assistantMessages = afterHydrate.filter((item) => item.type === "assistant_message");
|
||||
expect(assistantMessages).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("hydrateTimeline keeps provider user_message items when no canonical user history exists", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-keep-user-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
@@ -5314,378 +5190,7 @@ test("hydrateTimeline preserves provider replay timestamps and marks missing one
|
||||
expect(timeline[1]?.timestamp).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
test("hydrateTimeline suppresses only matching canonical user_message messageId", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-partial-dedup-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class HistoryWithMixedUserMessagesSession extends TestAgentSession {
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "hello from user",
|
||||
messageId: "msg_client_hello",
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: "hi there" },
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "hello from user",
|
||||
messageId: "msg_provider_distinct",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class HistoryMixedClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new HistoryWithMixedUserMessagesSession(config);
|
||||
}
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("Not used in this test");
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new HistoryMixedClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000204",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
// Canonical user message that should dedupe the matching history item.
|
||||
manager.recordUserMessage(snapshot.id, "hello from user", {
|
||||
messageId: "msg_client_hello",
|
||||
});
|
||||
|
||||
await manager.hydrateTimelineFromProvider(snapshot.id);
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
expect(userMessages).toHaveLength(2);
|
||||
expect(userMessages.map((item) => item.messageId)).toEqual([
|
||||
"msg_client_hello",
|
||||
"msg_provider_distinct",
|
||||
]);
|
||||
expect(userMessages.map((item) => item.text)).toEqual(["hello from user", "hello from user"]);
|
||||
});
|
||||
|
||||
test("recordUserMessage normalizes blank/whitespace messageId to undefined", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-blank-msgid-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000201",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
// Empty string should be treated as missing
|
||||
manager.recordUserMessage(snapshot.id, "test empty", {
|
||||
messageId: "",
|
||||
});
|
||||
|
||||
// Whitespace-only should be treated as missing
|
||||
manager.recordUserMessage(snapshot.id, "test whitespace", {
|
||||
messageId: " ",
|
||||
});
|
||||
|
||||
// Valid messageId should be preserved
|
||||
manager.recordUserMessage(snapshot.id, "test valid", {
|
||||
messageId: "msg_valid_123",
|
||||
});
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
|
||||
item.type === "user_message",
|
||||
);
|
||||
|
||||
expect(userMessages).toHaveLength(3);
|
||||
// Empty string → undefined (not empty string)
|
||||
expect(userMessages[0].messageId).toBeUndefined();
|
||||
// Whitespace → undefined
|
||||
expect(userMessages[1].messageId).toBeUndefined();
|
||||
// Valid → preserved
|
||||
expect(userMessages[2].messageId).toBe("msg_valid_123");
|
||||
});
|
||||
|
||||
test("recordUserMessage preserves provided messageId in timeline item and dispatched event", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-msgid-passthrough-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000202",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
manager.subscribe((event) => {
|
||||
if (event.type === "agent_stream") {
|
||||
events.push(event.event);
|
||||
}
|
||||
});
|
||||
|
||||
const clientMsgId = "msg_abc_123_def";
|
||||
manager.recordUserMessage(snapshot.id, "hello", {
|
||||
messageId: clientMsgId,
|
||||
});
|
||||
|
||||
// Timeline item should have the messageId
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMsg = timeline.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
|
||||
item.type === "user_message",
|
||||
);
|
||||
expect(userMsg).toBeDefined();
|
||||
expect(userMsg!.messageId).toBe(clientMsgId);
|
||||
|
||||
// Dispatched stream event should also carry the messageId
|
||||
const streamEvent = events.find((e) => e.type === "timeline" && e.item.type === "user_message");
|
||||
expect(streamEvent).toBeDefined();
|
||||
if (streamEvent?.type === "timeline") {
|
||||
expect((streamEvent.item as { type: "user_message"; messageId?: string }).messageId).toBe(
|
||||
clientMsgId,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("live provider user_message echo is suppressed when recordUserMessage was called first", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-echo-dedup-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
// Session whose live turn echoes the user message (as Claude does)
|
||||
class EchoUserMessageSession extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
const turnId = "turn-echo-1";
|
||||
setTimeout(() => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
// Provider echoes user message during live run
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "hello from user",
|
||||
messageId: "msg_client_echo_1",
|
||||
},
|
||||
turnId,
|
||||
});
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: "hello from assistant" },
|
||||
turnId,
|
||||
});
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
}, 0);
|
||||
return { turnId };
|
||||
}
|
||||
}
|
||||
|
||||
class EchoClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new EchoUserMessageSession(config);
|
||||
}
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new EchoClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000400",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
// Canonical recording (what session.ts does before starting stream)
|
||||
manager.recordUserMessage(snapshot.id, "hello from user", {
|
||||
messageId: "msg_client_echo_1",
|
||||
});
|
||||
|
||||
// Run triggers startTurn(), which echoes user_message
|
||||
await manager.runAgent(snapshot.id, { text: "hello from user" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
// Should be exactly 1 (canonical), not 2 (canonical + provider echo)
|
||||
expect(userMessages).toHaveLength(1);
|
||||
// The canonical one must carry the client messageId for optimistic matching
|
||||
expect(userMessages[0].messageId).toBe("msg_client_echo_1");
|
||||
|
||||
// Assistant messages from the run should still appear
|
||||
const assistantMessages = timeline.filter((item) => item.type === "assistant_message");
|
||||
expect(assistantMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("live provider user_message with different messageId is NOT suppressed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-different-msgid-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class DifferentMessageIdSession extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
const turnId = "turn-diff-msgid-1";
|
||||
setTimeout(() => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "hello from user",
|
||||
messageId: "msg_provider_other",
|
||||
},
|
||||
turnId,
|
||||
});
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
}, 0);
|
||||
return { turnId };
|
||||
}
|
||||
}
|
||||
|
||||
class DifferentMessageIdClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new DifferentMessageIdSession(config);
|
||||
}
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new DifferentMessageIdClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000402",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
manager.recordUserMessage(snapshot.id, "hello from user", {
|
||||
messageId: "msg_client_echo_2",
|
||||
});
|
||||
|
||||
await manager.runAgent(snapshot.id, { text: "hello from user" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "user_message" }> =>
|
||||
item.type === "user_message",
|
||||
);
|
||||
expect(userMessages).toHaveLength(2);
|
||||
expect(userMessages.map((item) => item.messageId)).toEqual([
|
||||
"msg_client_echo_2",
|
||||
"msg_provider_other",
|
||||
]);
|
||||
});
|
||||
|
||||
test("live provider user_message without messageId is NOT suppressed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-no-msgid-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class NoMessageIdSession extends TestAgentSession {
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
const turnId = "turn-no-msgid-1";
|
||||
setTimeout(() => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
this.pushEvent({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "user_message", text: "hello from user" },
|
||||
turnId,
|
||||
});
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
}, 0);
|
||||
return { turnId };
|
||||
}
|
||||
}
|
||||
|
||||
class NoMessageIdClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new NoMessageIdSession(config);
|
||||
}
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("unused");
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new NoMessageIdClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000403",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
manager.recordUserMessage(snapshot.id, "hello from user", {
|
||||
messageId: "msg_client_echo_3",
|
||||
});
|
||||
|
||||
await manager.runAgent(snapshot.id, { text: "hello from user" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
expect(userMessages).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("provider user_message is NOT suppressed when no prior recordUserMessage", async () => {
|
||||
test("provider user_message is recorded from the live stream", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-prior-record-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -5738,7 +5243,6 @@ test("provider user_message is NOT suppressed when no prior recordUserMessage",
|
||||
|
||||
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||
|
||||
// No recordUserMessage — run directly
|
||||
await manager.runAgent(snapshot.id, { text: "do something" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
|
||||
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||
import { IMPORTABLE_PROVIDERS } from "./provider-registry.js";
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
|
||||
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
||||
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
||||
@@ -65,6 +66,9 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
type TimeoutResult = "completed" | "timed_out";
|
||||
@@ -132,6 +136,11 @@ export interface SubscribeOptions {
|
||||
replayState?: boolean;
|
||||
}
|
||||
|
||||
interface HydrateTimelineOptions {
|
||||
force?: boolean;
|
||||
broadcast?: boolean;
|
||||
}
|
||||
|
||||
export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & {
|
||||
/**
|
||||
* When set, only providers in this set are scanned, in addition to the
|
||||
@@ -220,7 +229,6 @@ interface StreamEventFlags {
|
||||
|
||||
interface HandleStreamEventOptions {
|
||||
fromHistory?: boolean;
|
||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
interface ManagedAgentBase {
|
||||
@@ -374,28 +382,6 @@ function validateAgentId(agentId: string, source: string): string {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function normalizeMessageId(messageId: string | undefined): string | undefined {
|
||||
if (typeof messageId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = messageId.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function isDuplicateLegacyUserMessage(
|
||||
item: AgentTimelineItem,
|
||||
canonicalUserMessagesById: Map<string, string>,
|
||||
): boolean {
|
||||
if (item.type !== "user_message") {
|
||||
return false;
|
||||
}
|
||||
const eventMessageId = normalizeMessageId(item.messageId);
|
||||
if (!eventMessageId) {
|
||||
return false;
|
||||
}
|
||||
return canonicalUserMessagesById.get(eventMessageId) === item.text;
|
||||
}
|
||||
|
||||
function buildExplicitTimelineSeedForRegister(
|
||||
now: Date,
|
||||
options:
|
||||
@@ -1459,39 +1445,6 @@ export class AgentManager {
|
||||
return true;
|
||||
}
|
||||
|
||||
recordUserMessage(
|
||||
agentId: string,
|
||||
text: string,
|
||||
options?: { messageId?: string; emitState?: boolean },
|
||||
): void {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const normalizedMessageId = normalizeMessageId(options?.messageId);
|
||||
const item: AgentTimelineItem = {
|
||||
type: "user_message",
|
||||
text,
|
||||
messageId: normalizedMessageId,
|
||||
};
|
||||
const updatedAt = this.touchUpdatedAt(agent);
|
||||
agent.lastUserMessageAt = updatedAt;
|
||||
const row = this.recordTimeline(agentId, item);
|
||||
this.dispatchStream(
|
||||
agentId,
|
||||
{
|
||||
type: "timeline",
|
||||
item,
|
||||
provider: agent.provider,
|
||||
},
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agentId),
|
||||
timestamp: row.timestamp,
|
||||
},
|
||||
);
|
||||
if (options?.emitState !== false) {
|
||||
this.emitState(agent);
|
||||
}
|
||||
}
|
||||
|
||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.touchUpdatedAt(agent);
|
||||
@@ -1984,9 +1937,47 @@ export class AgentManager {
|
||||
* timeline is empty (e.g., imported agents that have provider history
|
||||
* on disk but no persisted timeline rows). No-ops if already hydrated.
|
||||
*/
|
||||
async hydrateTimelineFromProvider(agentId: string): Promise<void> {
|
||||
async hydrateTimelineFromProvider(
|
||||
agentId: string,
|
||||
options?: HydrateTimelineOptions,
|
||||
): Promise<void> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
await this.hydrateTimelineFromLegacyProviderHistory(agent);
|
||||
await this.hydrateTimelineFromLegacyProviderHistory(agent, options);
|
||||
}
|
||||
|
||||
async rewind(agentId: string, messageId: string, mode: RewindMode): Promise<void> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const hadActiveRun =
|
||||
Boolean(agent.activeForegroundTurnId) || this.foregroundRuns.hasPendingRun(agentId);
|
||||
if (hadActiveRun) {
|
||||
await this.cancelAgentRun(agentId);
|
||||
}
|
||||
|
||||
const lock = this.foregroundRuns.createPendingRun(agentId);
|
||||
try {
|
||||
this.logger.info(
|
||||
{ agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.start",
|
||||
);
|
||||
await invokeRewindCapability(agent.session, { messageId, mode });
|
||||
if (mode !== "files") {
|
||||
await this.hydrateTimelineFromProvider(agentId, { force: true, broadcast: true });
|
||||
}
|
||||
await this.refreshRuntimeInfo(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.logger.info(
|
||||
{ agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.complete",
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
{ err: error, agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.failed",
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
this.foregroundRuns.settlePendingRun(agentId, lock.token);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
||||
@@ -2074,19 +2065,6 @@ export class AgentManager {
|
||||
return await this.durableTimelineStore.getLastItem(agentId);
|
||||
}
|
||||
|
||||
private async hasCommittedUserMessageFromStores(
|
||||
agentId: string,
|
||||
options: { messageId: string; text: string },
|
||||
): Promise<boolean> {
|
||||
if (this.timelineStore.hasCommittedUserMessage(agentId, options)) {
|
||||
return true;
|
||||
}
|
||||
if (!this.durableTimelineStore) {
|
||||
return false;
|
||||
}
|
||||
return await this.durableTimelineStore.hasCommittedUserMessage(agentId, options);
|
||||
}
|
||||
|
||||
async waitForAgentEvent(
|
||||
agentId: string,
|
||||
options?: WaitForAgentOptions,
|
||||
@@ -2627,20 +2605,53 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateTimelineFromLegacyProviderHistory(agent: ActiveManagedAgent): Promise<void> {
|
||||
if (agent.historyPrimed) {
|
||||
private async hydrateTimelineFromLegacyProviderHistory(
|
||||
agent: ActiveManagedAgent,
|
||||
options?: HydrateTimelineOptions,
|
||||
): Promise<void> {
|
||||
if (agent.historyPrimed && !options?.force) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.force) {
|
||||
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
|
||||
for await (const event of agent.session.streamHistory()) {
|
||||
if (event.type === "timeline") {
|
||||
historyEvents.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
this.agentStreamCoalescer.flushAndDiscard(agent.id);
|
||||
await this.deleteCommittedTimeline(agent.id);
|
||||
this.timelineStore.delete(agent.id);
|
||||
this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() });
|
||||
agent.historyPrimed = true;
|
||||
|
||||
for (const event of historyEvents) {
|
||||
const row = this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
||||
);
|
||||
if (options?.broadcast) {
|
||||
this.dispatchStream(agent.id, event, {
|
||||
seq: row.seq,
|
||||
epoch: this.timelineStore.getEpoch(agent.id),
|
||||
timestamp: row.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
return;
|
||||
}
|
||||
|
||||
agent.historyPrimed = true;
|
||||
const canonicalUserMessagesById = this.timelineStore.getCanonicalUserMessagesById(agent.id);
|
||||
try {
|
||||
for await (const event of agent.session.streamHistory()) {
|
||||
if (event.type !== "timeline") {
|
||||
continue;
|
||||
}
|
||||
if (isDuplicateLegacyUserMessage(event.item, canonicalUserMessagesById)) {
|
||||
continue;
|
||||
}
|
||||
this.recordTimeline(
|
||||
agent.id,
|
||||
event.item,
|
||||
@@ -2882,42 +2893,11 @@ export class AgentManager {
|
||||
private async onStreamTimelineEvent(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
||||
options:
|
||||
| {
|
||||
fromHistory?: boolean;
|
||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
||||
}
|
||||
| undefined;
|
||||
options: { fromHistory?: boolean } | undefined;
|
||||
isForegroundEvent: boolean;
|
||||
flags: StreamEventFlags;
|
||||
}): Promise<void> {
|
||||
const { agent, event, options, isForegroundEvent, flags } = params;
|
||||
// Skip provider-replayed user_message items during history hydration.
|
||||
if (options?.fromHistory && event.item.type === "user_message") {
|
||||
const eventMessageId = normalizeMessageId(event.item.messageId);
|
||||
if (eventMessageId) {
|
||||
const canonicalText = options?.canonicalUserMessagesById?.get(eventMessageId);
|
||||
if (canonicalText === event.item.text) {
|
||||
flags.shouldDispatchEvent = false;
|
||||
flags.shouldNotifyWaiters = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress user_message echoes for the active foreground turn.
|
||||
if (!options?.fromHistory && event.item.type === "user_message" && isForegroundEvent) {
|
||||
const eventMessageId = normalizeMessageId(event.item.messageId);
|
||||
if (
|
||||
eventMessageId &&
|
||||
(await this.hasCommittedUserMessageFromStores(agent.id, {
|
||||
messageId: eventMessageId,
|
||||
text: event.item.text,
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { agent, event, options, flags } = params;
|
||||
|
||||
if (options?.fromHistory) {
|
||||
this.recordTimeline(
|
||||
@@ -2971,12 +2951,7 @@ export class AgentManager {
|
||||
event: Extract<AgentStreamEvent, { type: "turn_failed" }>;
|
||||
eventTurnId: string | undefined;
|
||||
isForegroundEvent: boolean;
|
||||
options:
|
||||
| {
|
||||
fromHistory?: boolean;
|
||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
||||
}
|
||||
| undefined;
|
||||
options: { fromHistory?: boolean } | undefined;
|
||||
}): Promise<void> {
|
||||
const { agent, event, eventTurnId, isForegroundEvent, options } = params;
|
||||
this.logger.warn(
|
||||
@@ -3139,10 +3114,7 @@ export class AgentManager {
|
||||
agent: ActiveManagedAgent,
|
||||
provider: AgentProvider,
|
||||
message: string,
|
||||
options?: {
|
||||
fromHistory?: boolean;
|
||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
||||
},
|
||||
options?: { fromHistory?: boolean },
|
||||
): Promise<void> {
|
||||
if (options?.fromHistory) {
|
||||
return;
|
||||
|
||||
@@ -196,6 +196,9 @@ export function buildStoredAgentPayload(
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
} as const;
|
||||
|
||||
const createdAt = new Date(record.createdAt);
|
||||
|
||||
@@ -118,7 +118,7 @@ export interface SendPromptToAgentParams {
|
||||
agentId: string;
|
||||
/** Prompt to dispatch to the provider (may include image blocks or wrapped text). */
|
||||
prompt: AgentPromptInput;
|
||||
/** Raw user text to record in the timeline. Required when recordUserMessage is true. */
|
||||
/** Raw user text. Required when userMessageVisible is true. */
|
||||
userMessageText?: string;
|
||||
messageId?: string;
|
||||
runOptions?: AgentRunOptions;
|
||||
@@ -131,17 +131,17 @@ export interface SendPromptToAgentParams {
|
||||
*/
|
||||
unarchive?: boolean;
|
||||
/**
|
||||
* Default true. When false, the prompt is dispatched to the provider but
|
||||
* no user-message turn is added to the timeline — the app shows nothing.
|
||||
* Default true. When false, the prompt is dispatched to the provider as an
|
||||
* invisible/system prompt — the app shows no user-message turn.
|
||||
* Use false for system-injected prompts.
|
||||
*/
|
||||
recordUserMessage?: boolean;
|
||||
userMessageVisible?: boolean;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full send-prompt orchestration: (optional unarchive) → load → (optional
|
||||
* mode change) → (optional record user message) → start run.
|
||||
* mode change) → start run.
|
||||
*
|
||||
* Every surface that sends a prompt to an agent (Session/WS, MCP, CLI-through-MCP,
|
||||
* chat mentions, notify-on-finish) MUST go through this so behavior can never
|
||||
@@ -154,10 +154,10 @@ export async function sendPromptToAgent(
|
||||
params: SendPromptToAgentParams,
|
||||
): Promise<{ outOfBand: boolean }> {
|
||||
const unarchive = params.unarchive ?? true;
|
||||
const recordUserMessage = params.recordUserMessage ?? true;
|
||||
const userMessageVisible = params.userMessageVisible ?? true;
|
||||
|
||||
if (recordUserMessage && params.userMessageText === undefined) {
|
||||
throw new Error("userMessageText is required when recordUserMessage is true");
|
||||
if (userMessageVisible && params.userMessageText === undefined) {
|
||||
throw new Error("userMessageText is required when userMessageVisible is true");
|
||||
}
|
||||
|
||||
const record = await params.agentStorage.get(params.agentId);
|
||||
@@ -178,17 +178,6 @@ export async function sendPromptToAgent(
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
}
|
||||
|
||||
if (recordUserMessage && params.userMessageText !== undefined) {
|
||||
try {
|
||||
params.agentManager.recordUserMessage(params.agentId, params.userMessageText, {
|
||||
messageId: params.messageId,
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
params.logger.error({ err: error, agentId: params.agentId }, "Failed to record user message");
|
||||
}
|
||||
}
|
||||
|
||||
return startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
|
||||
replaceRunning: true,
|
||||
runOptions: params.runOptions,
|
||||
@@ -225,7 +214,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
agentId: callerAgentId,
|
||||
prompt: formatSystemNotificationPrompt(body),
|
||||
unarchive: false,
|
||||
recordUserMessage: false,
|
||||
userMessageVisible: false,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,6 +123,9 @@ export interface AgentCapabilityFlags {
|
||||
supportsMcpServers: boolean;
|
||||
supportsReasoningStream: boolean;
|
||||
supportsToolInvocations: boolean;
|
||||
supportsRewindConversation?: boolean;
|
||||
supportsRewindFiles?: boolean;
|
||||
supportsRewindBoth?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentPersistenceHandle {
|
||||
@@ -144,6 +147,7 @@ export interface AgentRunOptions {
|
||||
outputSchema?: unknown;
|
||||
resumeFrom?: AgentPersistenceHandle;
|
||||
maxThinkingTokens?: number;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface AgentUsage {
|
||||
@@ -545,6 +549,9 @@ export interface AgentSession {
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
revertConversation?(input: { messageId: string }): Promise<void>;
|
||||
revertFiles?(input: { messageId: string }): Promise<void>;
|
||||
revertBoth?(input: { messageId: string }): Promise<void>;
|
||||
/**
|
||||
* Out-of-band prompt handler. When non-null, the manager runs the returned
|
||||
* handler instead of allocating a turn. The handler emits stream events
|
||||
|
||||
@@ -56,10 +56,6 @@ export interface AgentTimelineStore {
|
||||
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
|
||||
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
|
||||
getLastAssistantMessage(agentId: string): Promise<string | null>;
|
||||
hasCommittedUserMessage(
|
||||
agentId: string,
|
||||
options: { messageId: string; text: string },
|
||||
): Promise<boolean>;
|
||||
deleteAgent(agentId: string): Promise<void>;
|
||||
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -26,14 +26,6 @@ function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
|
||||
return { ...row };
|
||||
}
|
||||
|
||||
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
|
||||
if (typeof messageId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = messageId.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
interface FetchContext {
|
||||
state: AgentTimelineState;
|
||||
direction: NonNullable<AgentTimelineFetchOptions["direction"]>;
|
||||
@@ -282,35 +274,6 @@ export class InMemoryAgentTimelineStore {
|
||||
return chunks.toReversed().join("");
|
||||
}
|
||||
|
||||
getCanonicalUserMessagesById(agentId: string): Map<string, string> {
|
||||
const entries = this.requireState(agentId).rows.flatMap<[string, string]>((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return [];
|
||||
}
|
||||
const messageId = normalizeTimelineMessageId(row.item.messageId);
|
||||
if (!messageId) {
|
||||
return [];
|
||||
}
|
||||
return [[messageId, row.item.text]];
|
||||
});
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
hasCommittedUserMessage(agentId: string, options: { messageId: string; text: string }): boolean {
|
||||
const messageId = normalizeTimelineMessageId(options.messageId);
|
||||
if (!messageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.requireState(agentId).rows.some((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return false;
|
||||
}
|
||||
const rowMessageId = normalizeTimelineMessageId(row.item.messageId);
|
||||
return rowMessageId === messageId && row.item.text === options.text;
|
||||
});
|
||||
}
|
||||
|
||||
private requireState(agentId: string): AgentTimelineState {
|
||||
const state = this.states.get(agentId);
|
||||
if (!state) {
|
||||
|
||||
@@ -116,7 +116,6 @@ function buildAgentManagerSpies() {
|
||||
return {
|
||||
createAgent: vi.fn(),
|
||||
waitForAgentEvent: vi.fn(),
|
||||
recordUserMessage: vi.fn(),
|
||||
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
@@ -1089,14 +1089,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
logger: childLogger,
|
||||
});
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt, {
|
||||
emitState: false,
|
||||
});
|
||||
} catch (error) {
|
||||
childLogger.error({ err: error, agentId: snapshot.id }, "Failed to record initial prompt");
|
||||
}
|
||||
|
||||
try {
|
||||
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
|
||||
if (notifyOnFinish && callerAgentId) {
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("createProviderEnv", () => {
|
||||
expect(env.PATH).toBe("/custom/path");
|
||||
});
|
||||
|
||||
test("strips parent Claude Code session env vars", () => {
|
||||
test("strips parent Claude Code session env vars without removing SDK child flags", () => {
|
||||
const base = {
|
||||
PATH: "/usr/bin",
|
||||
CLAUDECODE: "1",
|
||||
@@ -102,7 +102,7 @@ describe("createProviderEnv", () => {
|
||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||
expect(env.CLAUDE_CODE_SSE_PORT).toBeUndefined();
|
||||
expect(env.CLAUDE_AGENT_SDK_VERSION).toBeUndefined();
|
||||
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBeUndefined();
|
||||
expect(env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -174,7 +174,6 @@ const PARENT_SESSION_ENV_VARS = [
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SSE_PORT",
|
||||
"CLAUDE_AGENT_SDK_VERSION",
|
||||
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
|
||||
];
|
||||
|
||||
export interface ProviderEnvOptions {
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentPromptInput,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
AgentRuntimeInfo,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { wrapSessionProvider } from "./provider-registry.js";
|
||||
|
||||
type OptionalAgentSessionMethodName = {
|
||||
[K in keyof AgentSession]-?: undefined extends AgentSession[K]
|
||||
? NonNullable<AgentSession[K]> extends (...args: never[]) => unknown
|
||||
? K
|
||||
: never
|
||||
: never;
|
||||
}[keyof AgentSession];
|
||||
|
||||
const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
"setFeature",
|
||||
"revertConversation",
|
||||
"revertFiles",
|
||||
"revertBoth",
|
||||
"tryHandleOutOfBand",
|
||||
] as const satisfies readonly OptionalAgentSessionMethodName[];
|
||||
|
||||
type MissingOptionalAgentSessionMethod = Exclude<
|
||||
OptionalAgentSessionMethodName,
|
||||
(typeof OPTIONAL_AGENT_SESSION_METHOD_NAMES)[number]
|
||||
>;
|
||||
|
||||
const _allOptionalAgentSessionMethodsAreCovered: MissingOptionalAgentSessionMethod extends never
|
||||
? true
|
||||
: never = true;
|
||||
|
||||
const CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: true,
|
||||
supportsRewindBoth: true,
|
||||
};
|
||||
|
||||
const RUNTIME_INFO: AgentRuntimeInfo = {
|
||||
provider: "claude",
|
||||
sessionId: "session-1",
|
||||
};
|
||||
|
||||
class FakeSession implements AgentSession {
|
||||
readonly provider = "claude";
|
||||
readonly id = "session-1";
|
||||
readonly capabilities = CAPABILITIES;
|
||||
readonly features = [];
|
||||
readonly recordedCalls: string[] = [];
|
||||
|
||||
async run() {
|
||||
this.recordedCalls.push("run");
|
||||
return { timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn() {
|
||||
this.recordedCalls.push("startTurn");
|
||||
return { turnId: "turn-1" };
|
||||
}
|
||||
|
||||
subscribe(_callback: (event: AgentStreamEvent) => void) {
|
||||
this.recordedCalls.push("subscribe");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
async *streamHistory() {
|
||||
this.recordedCalls.push("streamHistory");
|
||||
yield* emptyHistory();
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
this.recordedCalls.push("getRuntimeInfo");
|
||||
return RUNTIME_INFO;
|
||||
}
|
||||
|
||||
async getAvailableModes() {
|
||||
this.recordedCalls.push("getAvailableModes");
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode() {
|
||||
this.recordedCalls.push("getCurrentMode");
|
||||
return null;
|
||||
}
|
||||
|
||||
async setMode(_modeId: string) {
|
||||
this.recordedCalls.push("setMode");
|
||||
}
|
||||
|
||||
getPendingPermissions() {
|
||||
this.recordedCalls.push("getPendingPermissions");
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission() {
|
||||
this.recordedCalls.push("respondToPermission");
|
||||
}
|
||||
|
||||
describePersistence() {
|
||||
this.recordedCalls.push("describePersistence");
|
||||
return null;
|
||||
}
|
||||
|
||||
async interrupt() {
|
||||
this.recordedCalls.push("interrupt");
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.recordedCalls.push("close");
|
||||
}
|
||||
|
||||
async listCommands() {
|
||||
this.recordedCalls.push("listCommands");
|
||||
return [];
|
||||
}
|
||||
|
||||
async setModel() {
|
||||
this.recordedCalls.push("setModel");
|
||||
}
|
||||
|
||||
async setThinkingOption() {
|
||||
this.recordedCalls.push("setThinkingOption");
|
||||
}
|
||||
|
||||
async setFeature() {
|
||||
this.recordedCalls.push("setFeature");
|
||||
}
|
||||
|
||||
async revertConversation() {
|
||||
this.recordedCalls.push("revertConversation");
|
||||
}
|
||||
|
||||
async revertFiles() {
|
||||
this.recordedCalls.push("revertFiles");
|
||||
}
|
||||
|
||||
async revertBoth() {
|
||||
this.recordedCalls.push("revertBoth");
|
||||
}
|
||||
|
||||
tryHandleOutOfBand(_prompt: AgentPromptInput) {
|
||||
this.recordedCalls.push("tryHandleOutOfBand");
|
||||
return {
|
||||
run: async () => {
|
||||
this.recordedCalls.push("tryHandleOutOfBand.run");
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function* emptyHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
for (const event of [] as AgentStreamEvent[]) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
describe("wrapSessionProvider", () => {
|
||||
test("forwards every optional AgentSession method", async () => {
|
||||
const session = new FakeSession();
|
||||
const wrapped = wrapSessionProvider("custom-claude", session);
|
||||
|
||||
await wrapped.listCommands?.();
|
||||
await wrapped.setModel?.("sonnet");
|
||||
await wrapped.setThinkingOption?.("high");
|
||||
await wrapped.setFeature?.("feature-1", true);
|
||||
await wrapped.revertConversation?.({ messageId: "message-1" });
|
||||
await wrapped.revertFiles?.({ messageId: "message-1" });
|
||||
await wrapped.revertBoth?.({ messageId: "message-1" });
|
||||
const handler = wrapped.tryHandleOutOfBand?.("/compact");
|
||||
await handler?.run({ emit: () => {} });
|
||||
|
||||
expect(session.recordedCalls).toEqual([
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
"setFeature",
|
||||
"revertConversation",
|
||||
"revertFiles",
|
||||
"revertBoth",
|
||||
"tryHandleOutOfBand",
|
||||
"tryHandleOutOfBand.run",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -331,7 +331,7 @@ function mergeModelAdditions(
|
||||
);
|
||||
}
|
||||
|
||||
function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession {
|
||||
export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession {
|
||||
return {
|
||||
provider,
|
||||
id: inner.id,
|
||||
@@ -360,6 +360,10 @@ function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): Agen
|
||||
setModel: inner.setModel?.bind(inner),
|
||||
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
||||
setFeature: inner.setFeature?.bind(inner),
|
||||
revertConversation: inner.revertConversation?.bind(inner),
|
||||
revertFiles: inner.revertFiles?.bind(inner),
|
||||
revertBoth: inner.revertBoth?.bind(inner),
|
||||
tryHandleOutOfBand: inner.tryHandleOutOfBand?.bind(inner),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,9 @@ const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
||||
|
||||
@@ -1519,6 +1519,12 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
const result = await session.run("turn");
|
||||
await session.close();
|
||||
|
||||
expect(result.timeline).toEqual([{ type: "assistant_message", text: "Here is the answer." }]);
|
||||
expect(result.timeline).toEqual([
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "Here is the answer.",
|
||||
messageId: "assistant-third-party-1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
|
||||
import { realClaudeRewindSdk, revertClaudeConversation, revertClaudeFiles } from "./rewind.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||
|
||||
import {
|
||||
@@ -191,6 +192,15 @@ interface PersistedTimelineEntry {
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface ClaudeRewindTurnAnchor {
|
||||
userMessageId: string;
|
||||
assistantMessageId: string | null;
|
||||
}
|
||||
|
||||
type ClaudeConversationRewindTarget =
|
||||
| { kind: "fresh-session" }
|
||||
| { kind: "fork"; messageId: string };
|
||||
|
||||
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
@@ -198,6 +208,9 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: true,
|
||||
supportsRewindBoth: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MODES: AgentMode[] = [
|
||||
@@ -1046,7 +1059,7 @@ class TimelineAssembler {
|
||||
!isClaudeTranscriptNoiseText(nextAssistantText)
|
||||
) {
|
||||
state.emittedAssistantLength = state.assistantText.length;
|
||||
items.push({ type: "assistant_message", text: nextAssistantText });
|
||||
items.push({ type: "assistant_message", text: nextAssistantText, messageId: state.id });
|
||||
}
|
||||
|
||||
const nextReasoningText = state.reasoningText.slice(state.emittedReasoningLength);
|
||||
@@ -1157,7 +1170,25 @@ function isSyntheticUserEntry(entry: unknown): boolean {
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
return candidate.isSynthetic === true || candidate.isMeta === true;
|
||||
return (
|
||||
candidate.isSynthetic === true || candidate.isMeta === true || Boolean(candidate.toolUseResult)
|
||||
);
|
||||
}
|
||||
|
||||
function isToolResultUserEntry(entry: unknown): boolean {
|
||||
const candidate = toObjectRecord(entry);
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const message = toObjectRecord(candidate.message);
|
||||
const content = message?.content;
|
||||
return (
|
||||
Array.isArray(content) && content.some((block) => toObjectRecord(block)?.type === "tool_result")
|
||||
);
|
||||
}
|
||||
|
||||
function isSyntheticHistoryUserEntry(entry: Record<string, unknown>): boolean {
|
||||
return isSyntheticUserEntry(entry);
|
||||
}
|
||||
|
||||
function firstTrimmedString(sources: readonly unknown[]): string | null {
|
||||
@@ -1170,6 +1201,15 @@ function firstTrimmedString(sources: readonly unknown[]): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function readTranscriptUuid(message: SDKMessage): string | null {
|
||||
const root = toObjectRecord(message) ?? {};
|
||||
const messageType = readTrimmedString(root.type);
|
||||
if (messageType !== "user" && messageType !== "assistant") {
|
||||
return null;
|
||||
}
|
||||
return firstTrimmedString([root.uuid]);
|
||||
}
|
||||
|
||||
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
||||
const root = toObjectRecord(message) ?? {};
|
||||
const messageType = readTrimmedString(root.type);
|
||||
@@ -1177,7 +1217,10 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
||||
const streamEventMessage = toObjectRecord(streamEvent?.message);
|
||||
const messageContainer = toObjectRecord(root.message);
|
||||
|
||||
const messageIdFromUuid = messageType === "user" ? root.uuid : undefined;
|
||||
const messageIdFromUuid =
|
||||
messageType === "user" || messageType === "assistant" || messageType === "system"
|
||||
? root.uuid
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
taskId: firstTrimmedString([
|
||||
@@ -1566,7 +1609,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private queryPumpPromise: Promise<void> | null = null;
|
||||
private queryRestartNeeded = false;
|
||||
private pendingInterruptAbort = false;
|
||||
private lastForegroundPromptText: string | null = null;
|
||||
private foregroundHasVisibleActivity = false;
|
||||
private activeTurnHasAssistantText = false;
|
||||
private lastContextWindowUsedTokens: number | undefined;
|
||||
@@ -1574,6 +1616,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private lastStreamRequestInputTokens: number | undefined;
|
||||
private lastStreamRequestOutputTokens: number | undefined;
|
||||
private userMessageIds: string[] = [];
|
||||
private readonly emittedUserMessageIds = new Set<string>();
|
||||
private readonly rewindTurnAnchors: ClaudeRewindTurnAnchor[] = [];
|
||||
private pendingFreshSessionId: string | null = null;
|
||||
private recentStderr = "";
|
||||
private closed = false;
|
||||
|
||||
@@ -1689,7 +1734,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const sdkMessage = this.toSdkUserMessage(prompt);
|
||||
this.lastForegroundPromptText = this.extractPromptText(prompt);
|
||||
const sdkUserMessageId =
|
||||
typeof sdkMessage.uuid === "string" && sdkMessage.uuid.length > 0 ? sdkMessage.uuid : null;
|
||||
this.rememberRewindUserAnchor(sdkUserMessageId);
|
||||
const turnId = this.createTurnId("foreground");
|
||||
this.activeForegroundTurnId = turnId;
|
||||
this.foregroundHasVisibleActivity = false;
|
||||
@@ -1727,6 +1774,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.startQueryPump();
|
||||
this.input.push(sdkMessage);
|
||||
setTimeout(() => {
|
||||
if (this.activeForegroundTurnId === turnId) {
|
||||
this.emitSubmittedUserMessage(sdkMessage, turnId);
|
||||
}
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
this.finishForegroundTurn(
|
||||
this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"),
|
||||
@@ -2004,6 +2056,36 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return Array.from(commandMap.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
const target = this.resolveConversationRewindTarget(input.messageId);
|
||||
if (target.kind === "fresh-session") {
|
||||
this.startFreshConversationSession();
|
||||
return;
|
||||
}
|
||||
await revertClaudeConversation({
|
||||
sdk: realClaudeRewindSdk,
|
||||
sessionId: this.claudeSessionId,
|
||||
messageId: target.messageId,
|
||||
resolveMessageId: (messageId) => this.resolveClaudeMessageId(messageId),
|
||||
setSessionId: (sessionId) => {
|
||||
this.rebindConversationSession(sessionId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revertFiles(input: { messageId: string }): Promise<void> {
|
||||
const messageId = await this.resolveClaudeMessageId(input.messageId);
|
||||
await revertClaudeFiles({
|
||||
query: await this.ensureQuery(),
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
|
||||
async revertBoth(input: { messageId: string }): Promise<void> {
|
||||
await this.revertFiles(input);
|
||||
await this.revertConversation(input);
|
||||
}
|
||||
|
||||
private resolveSlashCommandInvocation(prompt: AgentPromptInput): SlashCommandInvocation | null {
|
||||
if (typeof prompt !== "string") {
|
||||
return null;
|
||||
@@ -2150,10 +2232,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
};
|
||||
|
||||
const historyIds = this.readUserMessageIdsFromHistoryFile();
|
||||
for (let idx = historyIds.length - 1; idx >= 0; idx -= 1) {
|
||||
pushUnique(historyIds[idx]);
|
||||
}
|
||||
for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) {
|
||||
const entry = this.persistedHistory[idx];
|
||||
if (entry?.item.type === "user_message") {
|
||||
@@ -2167,35 +2245,49 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private readUserMessageIdsFromHistoryFile(): string[] {
|
||||
if (!this.claudeSessionId) {
|
||||
return [];
|
||||
}
|
||||
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
|
||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const ids: string[] = [];
|
||||
const content = fs.readFileSync(historyPath, "utf8");
|
||||
for (const line of content.split(/\n+/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const entry = JSON.parse(trimmed);
|
||||
if (entry?.type === "user" && typeof entry.uuid === "string") {
|
||||
ids.push(entry.uuid);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
} catch {
|
||||
return [];
|
||||
private rebindConversationSession(sessionId: string): void {
|
||||
const oldSessionId = this.claudeSessionId;
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = null;
|
||||
this.persistence = null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.queryRestartNeeded = true;
|
||||
this.persistedHistory = [];
|
||||
this.historyPending = false;
|
||||
this.userMessageIds = [];
|
||||
this.emittedUserMessageIds.clear();
|
||||
this.rewindTurnAnchors.length = 0;
|
||||
this.loadPersistedHistory(sessionId);
|
||||
if (oldSessionId && oldSessionId !== sessionId) {
|
||||
this.dispatchEvents([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
|
||||
},
|
||||
{
|
||||
type: "thread_started",
|
||||
provider: "claude",
|
||||
sessionId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private startFreshConversationSession(): void {
|
||||
const sessionId = randomUUID();
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = sessionId;
|
||||
this.persistence = null;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.queryRestartNeeded = true;
|
||||
this.persistedHistory = [];
|
||||
this.historyPending = false;
|
||||
this.userMessageIds = [];
|
||||
this.emittedUserMessageIds.clear();
|
||||
this.rewindTurnAnchors.length = 0;
|
||||
}
|
||||
|
||||
private rememberUserMessageId(messageId: string | null | undefined): void {
|
||||
if (typeof messageId !== "string" || messageId.length === 0) {
|
||||
return;
|
||||
@@ -2207,6 +2299,92 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.userMessageIds.push(messageId);
|
||||
}
|
||||
|
||||
private rememberEmittedUserMessageId(messageId: string | null | undefined): void {
|
||||
if (typeof messageId !== "string" || messageId.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.emittedUserMessageIds.add(messageId);
|
||||
}
|
||||
|
||||
private rememberRewindUserAnchor(userMessageId: string | null | undefined): void {
|
||||
if (typeof userMessageId !== "string" || userMessageId.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (this.rewindTurnAnchors.some((anchor) => anchor.userMessageId === userMessageId)) {
|
||||
return;
|
||||
}
|
||||
this.rewindTurnAnchors.push({
|
||||
userMessageId,
|
||||
assistantMessageId: null,
|
||||
});
|
||||
}
|
||||
|
||||
private rememberRewindAssistantAnchor(assistantMessageId: string | null | undefined): void {
|
||||
if (typeof assistantMessageId !== "string" || assistantMessageId.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (let index = this.rewindTurnAnchors.length - 1; index >= 0; index -= 1) {
|
||||
const anchor = this.rewindTurnAnchors[index];
|
||||
if (!anchor) {
|
||||
continue;
|
||||
}
|
||||
anchor.assistantMessageId = assistantMessageId;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private rememberTranscriptProgress(message: SDKMessage, messageId: string | null): void {
|
||||
if (!messageId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
message.type === "user" &&
|
||||
!isSyntheticUserEntry(message) &&
|
||||
!isToolResultUserEntry(message)
|
||||
) {
|
||||
this.rememberRewindUserAnchor(messageId);
|
||||
return;
|
||||
}
|
||||
if (message.type === "assistant") {
|
||||
this.rememberRewindAssistantAnchor(messageId);
|
||||
return;
|
||||
}
|
||||
if (message.type === "stream_event") {
|
||||
const event = toObjectRecord(message.event) ?? {};
|
||||
const eventType = readTrimmedString(event.type);
|
||||
if (eventType === "message_start") {
|
||||
this.rememberRewindAssistantAnchor(messageId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveClaudeMessageId(messageId: string): string {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
private resolveConversationRewindTarget(messageId: string): ClaudeConversationRewindTarget {
|
||||
const targetUserMessageId = this.resolveClaudeMessageId(messageId);
|
||||
const index = this.rewindTurnAnchors.findIndex(
|
||||
(anchor) => anchor.userMessageId === targetUserMessageId,
|
||||
);
|
||||
if (index < 0) {
|
||||
throw new Error(`Claude rewind target ${messageId} is not in the tracked conversation`);
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
return { kind: "fresh-session" };
|
||||
}
|
||||
|
||||
const previousTurn = this.rewindTurnAnchors[index - 1];
|
||||
if (!previousTurn?.assistantMessageId) {
|
||||
throw new Error(
|
||||
`Claude rewind cannot preserve turn ${index} because its assistant response id was not observed`,
|
||||
);
|
||||
}
|
||||
return { kind: "fork", messageId: previousTurn.assistantMessageId };
|
||||
}
|
||||
|
||||
private async ensureQuery(): Promise<Query> {
|
||||
if (this.query && !this.queryRestartNeeded) {
|
||||
return this.query;
|
||||
@@ -2353,6 +2531,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
},
|
||||
"Resolved Claude executable",
|
||||
);
|
||||
const sessionBinding: Pick<ClaudeOptions, "resume" | "sessionId"> = {};
|
||||
if (this.pendingFreshSessionId) {
|
||||
sessionBinding.sessionId = this.pendingFreshSessionId;
|
||||
} else if (this.claudeSessionId) {
|
||||
sessionBinding.resume = this.claudeSessionId;
|
||||
}
|
||||
|
||||
const base: ClaudeOptions = {
|
||||
cwd: this.config.cwd,
|
||||
includePartialMessages: true,
|
||||
@@ -2380,7 +2565,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
enableFileCheckpointing: true,
|
||||
// If we have a session ID from a previous query (e.g., after interrupt),
|
||||
// resume that session to continue the conversation history.
|
||||
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
||||
...sessionBinding,
|
||||
...(thinking ? { thinking } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...extraClaudeOptions,
|
||||
@@ -2396,7 +2581,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
base.model = this.config.model;
|
||||
}
|
||||
this.lastOptionsModel = base.model ?? null;
|
||||
if (this.claudeSessionId) {
|
||||
if (this.claudeSessionId && !this.pendingFreshSessionId) {
|
||||
base.resume = this.claudeSessionId;
|
||||
}
|
||||
if (this.runtimeSettings?.disallowedTools?.length) {
|
||||
@@ -2559,16 +2744,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private extractPromptText(prompt: AgentPromptInput): string | null {
|
||||
if (typeof prompt === "string") {
|
||||
return prompt;
|
||||
}
|
||||
const textParts = prompt
|
||||
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
||||
.map((block) => block.text);
|
||||
return textParts.length > 0 ? textParts.join("\n") : null;
|
||||
}
|
||||
|
||||
private async executeRewindTurn(
|
||||
_turnId: string,
|
||||
invocation: SlashCommandInvocation,
|
||||
@@ -2630,7 +2805,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
this.notifySubscribers(event);
|
||||
this.activeForegroundTurnId = null;
|
||||
this.lastForegroundPromptText = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
@@ -2646,7 +2820,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (terminalSeen) {
|
||||
if (this.activeForegroundTurnId) {
|
||||
this.activeForegroundTurnId = null;
|
||||
this.lastForegroundPromptText = null;
|
||||
this.cancelCurrentTurn = null;
|
||||
this.activeTurnHasAssistantText = false;
|
||||
this.syncTurnState("foreground turn terminal");
|
||||
@@ -2808,18 +2981,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private isEchoedForegroundUserMessage(event: AgentStreamEvent): boolean {
|
||||
if (
|
||||
event.type !== "timeline" ||
|
||||
event.item.type !== "user_message" ||
|
||||
!this.activeForegroundTurnId ||
|
||||
!this.lastForegroundPromptText
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return event.item.text.trim() === this.lastForegroundPromptText.trim();
|
||||
}
|
||||
|
||||
private shouldSuppressStaleResult(message: SDKMessage): boolean {
|
||||
// Suppress stale results from interrupted requests. The cancel path already
|
||||
// emitted the terminal event; this result is leftover from the killed API
|
||||
@@ -2862,6 +3023,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||
const identifiers = readEventIdentifiers(message);
|
||||
this.rememberTranscriptProgress(message, readTranscriptUuid(message));
|
||||
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -2895,12 +3057,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}) satisfies AgentStreamEvent,
|
||||
);
|
||||
|
||||
// User message dedup: suppress echoed user messages that match the foreground prompt
|
||||
const filteredMessageEvents = messageEvents.filter(
|
||||
(event) => !this.isEchoedForegroundUserMessage(event),
|
||||
);
|
||||
|
||||
const events = [...filteredMessageEvents, ...assistantTimelineEvents];
|
||||
const events = [...messageEvents, ...assistantTimelineEvents];
|
||||
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
@@ -3013,20 +3170,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
const sessionCapture = this.captureSessionIdFromMessage(message);
|
||||
if (sessionCapture.notice) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: sessionCapture.notice,
|
||||
});
|
||||
}
|
||||
if (sessionCapture.threadStartedSessionId) {
|
||||
events.push({
|
||||
type: "thread_started",
|
||||
provider: "claude",
|
||||
sessionId: sessionCapture.threadStartedSessionId,
|
||||
});
|
||||
if (message.type !== "system") {
|
||||
const sessionCapture = this.captureSessionIdFromMessage(message);
|
||||
if (sessionCapture.notice) {
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: sessionCapture.notice,
|
||||
});
|
||||
}
|
||||
if (sessionCapture.threadStartedSessionId) {
|
||||
events.push({
|
||||
type: "thread_started",
|
||||
provider: "claude",
|
||||
sessionId: sessionCapture.threadStartedSessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
@@ -3059,6 +3218,25 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return events;
|
||||
}
|
||||
|
||||
private emitSubmittedUserMessage(
|
||||
message: Extract<SDKMessage, { type: "user" }>,
|
||||
turnId: string,
|
||||
): void {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
this.appendUserMessageEvents(message, events);
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.foregroundHasVisibleActivity = true;
|
||||
for (const event of events) {
|
||||
if (event.type === "timeline") {
|
||||
this.notifySubscribers({ ...event, turnId });
|
||||
} else {
|
||||
this.notifySubscribers(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private appendSystemMessageEvents(
|
||||
message: Extract<SDKMessage, { type: "system" }>,
|
||||
events: AgentStreamEvent[],
|
||||
@@ -3162,7 +3340,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
const messageId =
|
||||
typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
|
||||
if (messageId && this.emittedUserMessageIds.has(messageId)) {
|
||||
return;
|
||||
}
|
||||
this.rememberUserMessageId(messageId);
|
||||
this.rememberEmittedUserMessageId(messageId);
|
||||
const content = message.message?.content;
|
||||
const taskNotificationItem = mapTaskNotificationUserContentToToolCall({
|
||||
content,
|
||||
@@ -3293,10 +3475,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
if (this.claudeSessionId === null) {
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = null;
|
||||
this.persistence = null;
|
||||
return { threadStartedSessionId: sessionId, notice: null };
|
||||
}
|
||||
if (this.claudeSessionId === sessionId) {
|
||||
this.pendingFreshSessionId = null;
|
||||
return { threadStartedSessionId: null, notice: null };
|
||||
}
|
||||
const oldSessionId = this.claudeSessionId;
|
||||
@@ -3308,6 +3492,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"Claude session ID changed in message; accepting new session",
|
||||
);
|
||||
this.claudeSessionId = sessionId;
|
||||
this.pendingFreshSessionId = null;
|
||||
this.persistence = null;
|
||||
return {
|
||||
threadStartedSessionId: sessionId,
|
||||
@@ -3338,9 +3523,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
if (existingSessionId === null) {
|
||||
this.claudeSessionId = newSessionId;
|
||||
this.pendingFreshSessionId = null;
|
||||
threadStartedSessionId = newSessionId;
|
||||
this.logger.debug({ sessionId: newSessionId }, "Claude session ID set for the first time");
|
||||
} else if (existingSessionId === newSessionId) {
|
||||
this.pendingFreshSessionId = null;
|
||||
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
|
||||
} else {
|
||||
// Session ID changed in an init message (e.g. a hook restarted Claude
|
||||
@@ -3350,6 +3537,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"Claude session ID changed in init message; accepting new session",
|
||||
);
|
||||
this.claudeSessionId = newSessionId;
|
||||
this.pendingFreshSessionId = null;
|
||||
threadStartedSessionId = newSessionId;
|
||||
notice = this.createClaudeSessionChangedNotice(existingSessionId, newSessionId);
|
||||
}
|
||||
@@ -3703,12 +3891,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (entry.isSidechain) {
|
||||
return;
|
||||
}
|
||||
if (entry.type === "user" && typeof entry.uuid === "string") {
|
||||
this.rememberUserMessageId(entry.uuid);
|
||||
}
|
||||
|
||||
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
||||
const items = this.convertHistoryEntry(entry);
|
||||
const isVisibleUserEntry =
|
||||
entry.type === "user" &&
|
||||
typeof entry.uuid === "string" &&
|
||||
!isSyntheticHistoryUserEntry(entry) &&
|
||||
!isToolResultUserEntry(entry);
|
||||
if (isVisibleUserEntry && typeof entry.uuid === "string") {
|
||||
this.rememberUserMessageId(entry.uuid);
|
||||
this.rememberRewindUserAnchor(entry.uuid);
|
||||
}
|
||||
if (entry.type === "assistant" && typeof entry.uuid === "string") {
|
||||
this.rememberRewindAssistantAnchor(entry.uuid);
|
||||
}
|
||||
|
||||
if (items.length > 0) {
|
||||
timeline.push(
|
||||
...items.map((item) => ({
|
||||
@@ -3722,10 +3920,25 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private resolveHistoryPath(sessionId: string): string | null {
|
||||
const cwd = this.config.cwd;
|
||||
if (!cwd) return null;
|
||||
const sanitized = sanitizeClaudeProjectPath(cwd);
|
||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
const dir = path.join(configDir, "projects", sanitized);
|
||||
return path.join(dir, `${sessionId}.jsonl`);
|
||||
const candidates = [cwd];
|
||||
try {
|
||||
const realCwd = fs.realpathSync(cwd);
|
||||
if (realCwd !== cwd) {
|
||||
candidates.push(realCwd);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the configured cwd when the path has already disappeared.
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const sanitized = sanitizeClaudeProjectPath(candidate);
|
||||
const historyPath = path.join(configDir, "projects", sanitized, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(historyPath)) {
|
||||
return historyPath;
|
||||
}
|
||||
}
|
||||
const sanitized = sanitizeClaudeProjectPath(cwd);
|
||||
return path.join(configDir, "projects", sanitized, `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
|
||||
@@ -4409,6 +4622,25 @@ interface ClaudeHistoryEntry {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function mapAssistantHistoryBlocksWithMessageId(
|
||||
entry: ClaudeHistoryEntry,
|
||||
content: string | ClaudeContentChunk[],
|
||||
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
|
||||
): AgentTimelineItem[] {
|
||||
const items = mapBlocks(content);
|
||||
const assistantMessageId =
|
||||
typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null;
|
||||
if (!assistantMessageId) {
|
||||
return items;
|
||||
}
|
||||
for (const item of items) {
|
||||
if (item.type === "assistant_message" && !item.messageId) {
|
||||
item.messageId = assistantMessageId;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function convertClaudeHistoryEntryPreamble(
|
||||
entry: ClaudeHistoryEntry,
|
||||
): { shortCircuit: AgentTimelineItem[] } | { proceed: { content: unknown } } {
|
||||
@@ -4504,7 +4736,7 @@ export function convertClaudeHistoryEntry(
|
||||
}
|
||||
|
||||
if (entry.type === "assistant" && contentValue) {
|
||||
return mapBlocks(contentValue);
|
||||
return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
|
||||
}
|
||||
|
||||
return timeline;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import {
|
||||
revertClaudeConversation,
|
||||
revertClaudeConversationAndFiles,
|
||||
revertClaudeFiles,
|
||||
} from "./rewind.js";
|
||||
import { FakeClaudeSdk } from "./test-rewind-claude-sdk.js";
|
||||
|
||||
describe("Claude rewind", () => {
|
||||
test("forks the conversation up to the user message", async () => {
|
||||
const claude = new FakeClaudeSdk();
|
||||
let sessionId = "original-session";
|
||||
|
||||
await revertClaudeConversation({
|
||||
sdk: claude,
|
||||
sessionId,
|
||||
messageId: "user-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(claude.recordedForks).toEqual([{ upToMessageId: "user-message-1" }]);
|
||||
expect(sessionId).toBe("forked-session-1");
|
||||
});
|
||||
|
||||
test("translates Paseo timeline message ids before forking", async () => {
|
||||
const claude = new FakeClaudeSdk();
|
||||
let sessionId = "original-session";
|
||||
|
||||
await revertClaudeConversation({
|
||||
sdk: claude,
|
||||
sessionId,
|
||||
messageId: "timeline-message-1",
|
||||
resolveMessageId: () => "claude-jsonl-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(claude.recordedForks).toEqual([{ upToMessageId: "claude-jsonl-message-1" }]);
|
||||
expect(sessionId).toBe("forked-session-1");
|
||||
});
|
||||
|
||||
test("rewinds tracked files to the user message", async () => {
|
||||
const claude = new FakeClaudeSdk();
|
||||
|
||||
await revertClaudeFiles({
|
||||
query: claude.createQuery() as Query,
|
||||
messageId: "user-message-1",
|
||||
});
|
||||
|
||||
expect(claude.recordedFileRewinds).toEqual([{ userMessageId: "user-message-1" }]);
|
||||
});
|
||||
|
||||
test("translates Paseo timeline message ids before rewinding files", async () => {
|
||||
const claude = new FakeClaudeSdk();
|
||||
|
||||
await revertClaudeFiles({
|
||||
query: claude.createQuery() as Query,
|
||||
messageId: "timeline-message-1",
|
||||
resolveMessageId: () => "claude-jsonl-message-1",
|
||||
});
|
||||
|
||||
expect(claude.recordedFileRewinds).toEqual([{ userMessageId: "claude-jsonl-message-1" }]);
|
||||
});
|
||||
|
||||
test("rebinds the Claude session before composed rewind returns for rehydrate", async () => {
|
||||
const claude = new FakeClaudeSdk();
|
||||
claude.setNextSessionId("forked-before-rehydrate");
|
||||
let sessionId = "original-session";
|
||||
|
||||
await revertClaudeConversationAndFiles({
|
||||
sdk: claude,
|
||||
query: claude.createQuery() as Query,
|
||||
sessionId,
|
||||
messageId: "user-message-1",
|
||||
setSessionId: (nextSessionId) => {
|
||||
sessionId = nextSessionId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(claude.recordedFileRewinds).toEqual([{ userMessageId: "user-message-1" }]);
|
||||
expect(claude.recordedForks).toEqual([{ upToMessageId: "user-message-1" }]);
|
||||
expect(sessionId).toBe("forked-before-rehydrate");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { forkSession as claudeForkSession, type Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export interface ClaudeRewindSdk {
|
||||
forkSession(
|
||||
sessionId: string,
|
||||
options: { upToMessageId: string },
|
||||
): Promise<{ sessionId: string }>;
|
||||
}
|
||||
|
||||
export const realClaudeRewindSdk: ClaudeRewindSdk = {
|
||||
forkSession: claudeForkSession,
|
||||
};
|
||||
|
||||
export async function revertClaudeConversation(input: {
|
||||
sdk: ClaudeRewindSdk;
|
||||
sessionId: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
}): Promise<void> {
|
||||
if (!input.sessionId) {
|
||||
throw new Error("Claude session is not ready for rewind");
|
||||
}
|
||||
const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId;
|
||||
const fork = await input.sdk.forkSession(input.sessionId, {
|
||||
upToMessageId: messageId,
|
||||
});
|
||||
input.setSessionId(fork.sessionId);
|
||||
}
|
||||
|
||||
export async function revertClaudeFiles(input: {
|
||||
query: Query;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
}): Promise<void> {
|
||||
const messageId = (await input.resolveMessageId?.(input.messageId)) ?? input.messageId;
|
||||
const result = await input.query.rewindFiles(messageId, { dryRun: false });
|
||||
if (!result.canRewind) {
|
||||
throw new Error(result.error ?? `No file checkpoint found for message ${messageId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function revertClaudeConversationAndFiles(input: {
|
||||
sdk: ClaudeRewindSdk;
|
||||
query: Query;
|
||||
sessionId: string | null;
|
||||
messageId: string;
|
||||
resolveMessageId?: (messageId: string) => string | Promise<string>;
|
||||
setSessionId: (sessionId: string) => void;
|
||||
}): Promise<void> {
|
||||
await revertClaudeFiles({
|
||||
query: input.query,
|
||||
messageId: input.messageId,
|
||||
resolveMessageId: input.resolveMessageId,
|
||||
});
|
||||
await revertClaudeConversation(input);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ClaudeRewindSdk } from "./rewind.js";
|
||||
|
||||
export class FakeClaudeSdk implements ClaudeRewindSdk {
|
||||
readonly recordedForks: Array<{ upToMessageId: string }> = [];
|
||||
readonly recordedFileRewinds: Array<{ userMessageId: string }> = [];
|
||||
|
||||
private nextSessionId = "forked-session-1";
|
||||
|
||||
setNextSessionId(sessionId: string): void {
|
||||
this.nextSessionId = sessionId;
|
||||
}
|
||||
|
||||
async forkSession(
|
||||
_sessionId: string,
|
||||
options: { upToMessageId: string },
|
||||
): Promise<{ sessionId: string }> {
|
||||
this.recordedForks.push({ upToMessageId: options.upToMessageId });
|
||||
return { sessionId: this.nextSessionId };
|
||||
}
|
||||
|
||||
createQuery(): {
|
||||
rewindFiles: (
|
||||
userMessageId: string,
|
||||
options?: { dryRun?: boolean },
|
||||
) => Promise<{
|
||||
canRewind: boolean;
|
||||
}>;
|
||||
} {
|
||||
return {
|
||||
rewindFiles: async (userMessageId: string) => {
|
||||
this.recordedFileRewinds.push({ userMessageId });
|
||||
return { canRewind: true };
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import { __codexAppServerInternals } from "./codex-app-server-agent.js";
|
||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
@@ -74,7 +74,7 @@ function createSessionHarness(
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
|
||||
});
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
const session = new CodexAppServerAgentSession(
|
||||
{ ...config, provider: CODEX_PROVIDER },
|
||||
null,
|
||||
options.logger ?? createTestLogger(),
|
||||
|
||||
@@ -15,12 +15,20 @@ import type {
|
||||
AgentStreamEvent,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
__codexAppServerInternals,
|
||||
buildCodexAppServerEnv,
|
||||
CodexAppServerAgentClient,
|
||||
CodexAppServerAgentSession,
|
||||
codexAppServerTurnInputFromPrompt,
|
||||
listCodexSkills,
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
mapCodexPlanToToolCall,
|
||||
normalizeCodexOutputSchema,
|
||||
toAgentUsage,
|
||||
} from "./codex-app-server-agent.js";
|
||||
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
type FakeCodexAppServer,
|
||||
waitForNextPermission,
|
||||
} from "./codex/test-utils/fake-app-server.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
@@ -76,7 +84,7 @@ function createSession(
|
||||
configOverrides: Partial<AgentSessionConfig> = {},
|
||||
options: { goalsEnabled?: boolean; autoReviewEnabled?: boolean } = {},
|
||||
): CodexTestSession {
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig(configOverrides),
|
||||
null,
|
||||
createTestLogger(),
|
||||
@@ -106,6 +114,25 @@ function markdownImageSource(markdown: string): string {
|
||||
return match[1].replace(/\\\)/g, ")");
|
||||
}
|
||||
|
||||
function emitCodexUserMessage(
|
||||
appServer: FakeCodexAppServer,
|
||||
input: { id: string; text: string; threadId?: string },
|
||||
): void {
|
||||
appServer.child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
method: "item/started",
|
||||
params: {
|
||||
threadId: input.threadId ?? "thread-1",
|
||||
item: {
|
||||
type: "userMessage",
|
||||
id: input.id,
|
||||
content: [{ type: "text", text: input.text }],
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
type CapturedFakeCodexRecord = Record<string, unknown>;
|
||||
|
||||
async function runCustomCodexProviderTurn(
|
||||
@@ -380,7 +407,7 @@ describe("Codex app-server provider", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ thinkingOptionId: "medium" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
@@ -411,7 +438,7 @@ describe("Codex app-server provider", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ thinkingOptionId: "medium" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
@@ -437,7 +464,7 @@ describe("Codex app-server provider", () => {
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
|
||||
const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger());
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
|
||||
try {
|
||||
const disposePromise = client.dispose();
|
||||
@@ -459,7 +486,7 @@ describe("Codex app-server provider", () => {
|
||||
"collaborationMode/list": () => ({ data: [] }),
|
||||
"skills/list": () => ({ data: [] }),
|
||||
});
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
@@ -507,6 +534,32 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rewinds the conversation to a freshly emitted Codex user message id", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
await session.startTurn("remember first");
|
||||
emitCodexUserMessage(appServer, { id: "codex-first", text: "remember first" });
|
||||
appServer.completeTurn();
|
||||
await session.startTurn("remember second");
|
||||
emitCodexUserMessage(appServer, { id: "codex-second", text: "remember second" });
|
||||
appServer.completeTurn();
|
||||
|
||||
await session.revertConversation({ messageId: "codex-first" });
|
||||
|
||||
expect(appServer.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 2 }]);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
sessionId: "forked-thread",
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("configures Codex app-server to use a custom provider base URL", async () => {
|
||||
const capturedRequests = await runCustomCodexProviderTurn(
|
||||
"codex-iisb",
|
||||
@@ -557,7 +610,7 @@ describe("Codex app-server provider", () => {
|
||||
},
|
||||
"thread/resume": () => {
|
||||
threadRequests.push("thread/resume");
|
||||
return Promise.reject(new Error("no rollout found for thread id archived-thread-id"));
|
||||
return Promise.reject(new Error("no tool-call found for thread id archived-thread-id"));
|
||||
},
|
||||
"thread/start": () => {
|
||||
threadRequests.push("thread/start");
|
||||
@@ -604,7 +657,7 @@ describe("Codex app-server provider", () => {
|
||||
(error) => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toContain(
|
||||
"no rollout found for thread id archived-thread-id",
|
||||
"no tool-call found for thread id archived-thread-id",
|
||||
);
|
||||
return "rejected" as const;
|
||||
},
|
||||
@@ -637,9 +690,7 @@ describe("Codex app-server provider", () => {
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(
|
||||
__codexAppServerInternals.listCodexSkills(cwd, workspaceGitService),
|
||||
).resolves.toContainEqual({
|
||||
await expect(listCodexSkills(cwd, workspaceGitService)).resolves.toContainEqual({
|
||||
name: "shipper",
|
||||
description: "Ship changes carefully.",
|
||||
argumentHint: "",
|
||||
@@ -654,7 +705,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("extracts context window usage from snake_case token payloads", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.toAgentUsage({
|
||||
toAgentUsage({
|
||||
model_context_window: 200000,
|
||||
last: {
|
||||
total_tokens: 50000,
|
||||
@@ -674,7 +725,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("extracts context window usage from camelCase token payloads", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.toAgentUsage({
|
||||
toAgentUsage({
|
||||
modelContextWindow: 200000,
|
||||
last: {
|
||||
totalTokens: 50000,
|
||||
@@ -694,7 +745,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("keeps existing usage behavior when context window fields are missing", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.toAgentUsage({
|
||||
toAgentUsage({
|
||||
last: {
|
||||
inputTokens: 30000,
|
||||
cachedInputTokens: 5000,
|
||||
@@ -710,7 +761,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("excludes invalid context window values", () => {
|
||||
expect(
|
||||
__codexAppServerInternals.toAgentUsage({
|
||||
toAgentUsage({
|
||||
model_context_window: Number.NaN,
|
||||
modelContextWindow: "200000",
|
||||
last: {
|
||||
@@ -748,7 +799,7 @@ describe("Codex app-server provider", () => {
|
||||
required: ["overall"],
|
||||
};
|
||||
|
||||
const normalized = __codexAppServerInternals.normalizeCodexOutputSchema(input);
|
||||
const normalized = normalizeCodexOutputSchema(input);
|
||||
|
||||
expect(normalized).toEqual({
|
||||
type: "object",
|
||||
@@ -1013,7 +1064,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
test("maps patch notifications with array-style changes and alias diff keys", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
const item = mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-array-alias",
|
||||
changes: [
|
||||
{
|
||||
@@ -1036,7 +1087,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
test("maps Codex plan markdown to a synthetic plan tool call", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPlanToToolCall({
|
||||
const item = mapCodexPlanToToolCall({
|
||||
callId: "plan-turn-1",
|
||||
text: "### Login Screen\n- Build layout\n- Add validation",
|
||||
});
|
||||
@@ -1055,7 +1106,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
test("maps patch notifications with object-style single change payloads", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
const item = mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-object-single",
|
||||
changes: {
|
||||
path: "/tmp/repo/src/object-single.ts",
|
||||
@@ -1076,7 +1127,7 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
test("maps patch notifications with file_path aliases in array-style changes", () => {
|
||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
||||
const item = mapCodexPatchNotificationToToolCall({
|
||||
callId: "patch-array-file-path",
|
||||
changes: [
|
||||
{
|
||||
@@ -1105,7 +1156,7 @@ describe("Codex app-server provider", () => {
|
||||
PASEO_TEST_FLAG: "codex-launch-value",
|
||||
},
|
||||
};
|
||||
const env = __codexAppServerInternals.buildCodexAppServerEnv(
|
||||
const env = buildCodexAppServerEnv(
|
||||
{
|
||||
env: {
|
||||
PASEO_AGENT_ID: "runtime-value",
|
||||
@@ -1499,6 +1550,7 @@ describe("Codex app-server provider", () => {
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Check OpenCode timestamps.",
|
||||
messageId: "user-history",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1573,6 +1625,40 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("captures live Codex user message ids from item events", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const userMessageItem = {
|
||||
type: "userMessage",
|
||||
id: "codex-user-live-1",
|
||||
content: [{ type: "text", text: "Use the native Codex id." }],
|
||||
};
|
||||
|
||||
asInternals(session).handleNotification("item/started", {
|
||||
threadId: "test-thread",
|
||||
item: userMessageItem,
|
||||
});
|
||||
asInternals(session).handleNotification("item/completed", {
|
||||
threadId: "test-thread",
|
||||
item: userMessageItem,
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
turnId: "test-turn",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Use the native Codex id.",
|
||||
messageId: "codex-user-live-1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits Codex context compaction markers from live thread items", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
@@ -1694,7 +1780,7 @@ describe("Codex app-server provider", () => {
|
||||
return { data: [] };
|
||||
}
|
||||
if (method === "thread/resume") {
|
||||
throw new Error("no rollout found for thread id archived-thread-id");
|
||||
throw new Error("no tool-call found for thread id archived-thread-id");
|
||||
}
|
||||
if (method === "thread/start") {
|
||||
return { thread: { id: "replacement-empty-thread-id" } };
|
||||
@@ -1704,7 +1790,7 @@ describe("Codex app-server provider", () => {
|
||||
};
|
||||
|
||||
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
|
||||
"no rollout found for thread id archived-thread-id",
|
||||
"no tool-call found for thread id archived-thread-id",
|
||||
);
|
||||
|
||||
expect(session.currentThreadId).toBe("archived-thread-id");
|
||||
|
||||
@@ -44,7 +44,7 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import { curateAgentActivity } from "../activity-curator.js";
|
||||
import {
|
||||
mapCodexRolloutToolCall,
|
||||
mapCodexToolCallEnvelope,
|
||||
mapCodexToolCallFromThreadItem,
|
||||
} from "./codex/tool-call-mapper.js";
|
||||
import {
|
||||
@@ -60,8 +60,15 @@ import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mappe
|
||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||
import {
|
||||
CodexAppServerClient,
|
||||
parseCodexThreadForkResponse,
|
||||
parseCodexThreadRollbackResponse,
|
||||
type CodexThreadForkParams,
|
||||
type CodexThreadForkResponse,
|
||||
type CodexThreadRollbackParams,
|
||||
type CodexThreadRollbackResponse,
|
||||
type CodexAppServerTraceContext,
|
||||
} from "./codex/app-server-transport.js";
|
||||
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
|
||||
import {
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
type ProviderImageOutput,
|
||||
@@ -158,6 +165,9 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
const CODEX_MODES: AgentMode[] = [
|
||||
@@ -183,6 +193,8 @@ const DEFAULT_CODEX_MODE_ID = "auto";
|
||||
|
||||
interface CodexAppServerClientLike {
|
||||
request(method: string, params?: unknown): Promise<unknown>;
|
||||
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
|
||||
rollbackThread?(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse>;
|
||||
notify(method: string, params?: unknown): void;
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
@@ -343,7 +355,7 @@ function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): un
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
|
||||
export function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
|
||||
if (!isSchemaRecord(schema)) {
|
||||
throw new Error("Codex structured outputs require a JSON object schema.");
|
||||
}
|
||||
@@ -384,7 +396,7 @@ function codexMicrosoftStorePackageRoot(): string | null {
|
||||
return path.join(localAppData, "Packages");
|
||||
}
|
||||
|
||||
async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
||||
export async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
||||
if (process.platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
@@ -425,7 +437,7 @@ async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findDefaultCodexBinary(): Promise<string | null> {
|
||||
export async function findDefaultCodexBinary(): Promise<string | null> {
|
||||
return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary());
|
||||
}
|
||||
|
||||
@@ -597,7 +609,7 @@ async function listCodexCustomPrompts(): Promise<AgentSlashCommand[]> {
|
||||
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
async function listCodexSkills(
|
||||
export async function listCodexSkills(
|
||||
cwd: string,
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
||||
): Promise<AgentSlashCommand[]> {
|
||||
@@ -796,7 +808,7 @@ function filterCodexThreadsByCwd(
|
||||
return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd));
|
||||
}
|
||||
|
||||
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
||||
export function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
||||
const usage = toObjectRecord(tokenUsage);
|
||||
if (!usage) return undefined;
|
||||
const last = toObjectRecord(usage.last);
|
||||
@@ -838,7 +850,7 @@ function normalizePlanMarkdown(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
|
||||
export function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
|
||||
const lines = steps
|
||||
.map((entry) => entry.step.trim())
|
||||
.filter((step) => step.length > 0)
|
||||
@@ -851,7 +863,7 @@ function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): st
|
||||
return normalizePlanMarkdown(lines.join("\n"));
|
||||
}
|
||||
|
||||
function mapCodexPlanToToolCall(params: {
|
||||
export function mapCodexPlanToToolCall(params: {
|
||||
callId: string;
|
||||
text: string;
|
||||
}): ToolCallTimelineItem | null {
|
||||
@@ -935,7 +947,7 @@ interface CodexQuestionPrompt {
|
||||
isSecret?: boolean;
|
||||
}
|
||||
|
||||
function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
|
||||
export function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
@@ -985,7 +997,7 @@ function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
|
||||
return questions;
|
||||
}
|
||||
|
||||
function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
|
||||
export function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
|
||||
return questions
|
||||
.map((question) => {
|
||||
const lines = [`${question.header}: ${question.question}`];
|
||||
@@ -998,7 +1010,7 @@ function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function mapCodexQuestionRequestToToolCall(params: {
|
||||
export function mapCodexQuestionRequestToToolCall(params: {
|
||||
callId: string;
|
||||
questions: CodexQuestionPrompt[];
|
||||
status: ToolCallTimelineItem["status"];
|
||||
@@ -1360,7 +1372,7 @@ function mapCodexExecNotificationToToolCall(params: {
|
||||
? { exitCode: params.exitCode }
|
||||
: {}),
|
||||
};
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
const mapped = mapCodexToolCallEnvelope({
|
||||
callId: params.callId ?? null,
|
||||
name: "shell",
|
||||
input: {
|
||||
@@ -1377,7 +1389,7 @@ function mapCodexExecNotificationToToolCall(params: {
|
||||
return params.running ? toRunningToolCall(mapped) : mapped;
|
||||
}
|
||||
|
||||
function mapCodexPatchNotificationToToolCall(params: {
|
||||
export function mapCodexPatchNotificationToToolCall(params: {
|
||||
callId?: string | null;
|
||||
changes: unknown;
|
||||
cwd?: string | null;
|
||||
@@ -1393,7 +1405,7 @@ function mapCodexPatchNotificationToToolCall(params: {
|
||||
.find((value): value is string => typeof value === "string" && value.length > 0);
|
||||
const patchText = firstPatchText;
|
||||
const patchFields = codexPatchTextFields(patchText);
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
const mapped = mapCodexToolCallEnvelope({
|
||||
callId: params.callId ?? null,
|
||||
name: "apply_patch",
|
||||
input: firstPath
|
||||
@@ -1490,7 +1502,12 @@ function mapCodexThreadUserMessageItem(
|
||||
return null;
|
||||
}
|
||||
const text = extractUserText(normalizedItem.content) ?? "";
|
||||
return { type: "user_message", text };
|
||||
const messageId = nonEmptyString(normalizedItem.id);
|
||||
return {
|
||||
type: "user_message",
|
||||
text,
|
||||
...(messageId ? { messageId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function firstStringField(
|
||||
@@ -1605,7 +1622,7 @@ function mapCodexThreadImageItem(
|
||||
);
|
||||
}
|
||||
|
||||
function threadItemToTimeline(
|
||||
export function threadItemToTimeline(
|
||||
item: unknown,
|
||||
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
||||
): AgentTimelineItem | null {
|
||||
@@ -1713,6 +1730,26 @@ function readCodexThread(client: CodexAppServerClientLike, threadId: string): Pr
|
||||
});
|
||||
}
|
||||
|
||||
export async function forkCodexThread(
|
||||
client: CodexAppServerClientLike,
|
||||
params: CodexThreadForkParams,
|
||||
): Promise<CodexThreadForkResponse> {
|
||||
if (client.forkThread) {
|
||||
return client.forkThread(params);
|
||||
}
|
||||
return parseCodexThreadForkResponse(await client.request("thread/fork", params));
|
||||
}
|
||||
|
||||
export async function rollbackCodexThread(
|
||||
client: CodexAppServerClientLike,
|
||||
params: CodexThreadRollbackParams,
|
||||
): Promise<CodexThreadRollbackResponse> {
|
||||
if (client.rollbackThread) {
|
||||
return client.rollbackThread(params);
|
||||
}
|
||||
return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params));
|
||||
}
|
||||
|
||||
function toSandboxPolicy(type: string, networkAccess?: boolean): Record<string, unknown> {
|
||||
switch (type) {
|
||||
case "read-only":
|
||||
@@ -1999,6 +2036,18 @@ const CodexEventTurnDiffNotificationSchema = z
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const CodexEventThreadRolledBackNotificationSchema = z
|
||||
.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.literal("thread_rolled_back"),
|
||||
num_turns: z.number().int().nonnegative().optional(),
|
||||
numTurns: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type ParsedCodexNotification =
|
||||
| { kind: "thread_started"; threadId: string }
|
||||
| { kind: "turn_started"; turnId: string; threadId: string | null }
|
||||
@@ -2072,10 +2121,33 @@ type ParsedCodexNotification =
|
||||
itemId: string;
|
||||
delta: string | null;
|
||||
}
|
||||
| { kind: "thread_rolled_back"; numTurns: number }
|
||||
| { kind: "context_compacted"; threadId: string; turnId: string | null }
|
||||
| { kind: "invalid_payload"; method: string; params: unknown }
|
||||
| { kind: "unknown_method"; method: string; params: unknown };
|
||||
|
||||
type CodexDeltaNotification = Extract<
|
||||
ParsedCodexNotification,
|
||||
{
|
||||
kind:
|
||||
| "agent_message_delta"
|
||||
| "reasoning_delta"
|
||||
| "exec_command_output_delta"
|
||||
| "file_change_output_delta";
|
||||
}
|
||||
>;
|
||||
|
||||
function isCodexDeltaNotification(
|
||||
parsed: ParsedCodexNotification,
|
||||
): parsed is CodexDeltaNotification {
|
||||
return (
|
||||
parsed.kind === "agent_message_delta" ||
|
||||
parsed.kind === "reasoning_delta" ||
|
||||
parsed.kind === "exec_command_output_delta" ||
|
||||
parsed.kind === "file_change_output_delta"
|
||||
);
|
||||
}
|
||||
|
||||
const CodexNotificationSchema = z.union([
|
||||
z
|
||||
.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema })
|
||||
@@ -2548,6 +2620,24 @@ const CodexNotificationSchema = z.union([
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({
|
||||
method: z.literal("codex/event/thread_rolled_back"),
|
||||
params: CodexEventThreadRolledBackNotificationSchema,
|
||||
})
|
||||
.transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "thread_rolled_back",
|
||||
numTurns: params.msg.num_turns ?? params.msg.numTurns ?? 0,
|
||||
}),
|
||||
),
|
||||
z.object({ method: z.literal("codex/event/thread_rolled_back"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({
|
||||
kind: "invalid_payload",
|
||||
method,
|
||||
params,
|
||||
}),
|
||||
),
|
||||
z
|
||||
.object({ method: z.string(), params: z.unknown() })
|
||||
.transform(
|
||||
@@ -2685,7 +2775,7 @@ function toCodexTextInput(text: string): Extract<CodexAppServerUserInput, { type
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexAppServerEnv(
|
||||
export function buildCodexAppServerEnv(
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
launchEnv?: Record<string, string>,
|
||||
): NodeJS.ProcessEnv {
|
||||
@@ -2762,7 +2852,7 @@ interface CodexSubAgentCallState {
|
||||
childItems: Map<string, AgentTimelineItem>;
|
||||
}
|
||||
|
||||
class CodexAppServerAgentSession implements AgentSession {
|
||||
export class CodexAppServerAgentSession implements AgentSession {
|
||||
readonly provider = CODEX_PROVIDER;
|
||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||
|
||||
@@ -2810,6 +2900,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||
private latestUsage: AgentUsage | undefined;
|
||||
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
||||
private readonly userMessageTurnIndexes = new Map<string, number>();
|
||||
private readonly userMessageTurnIds: string[] = [];
|
||||
private pendingManualCompactionStarts = 0;
|
||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||
// Codex can report one completed compaction through both channels:
|
||||
@@ -3135,20 +3227,22 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
const client = this.client;
|
||||
const threadId = this.currentThreadId;
|
||||
|
||||
try {
|
||||
const timeline = await loadCodexThreadHistoryTimeline({
|
||||
threadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (threadIdToRead) => {
|
||||
return readCodexThread(client, threadIdToRead);
|
||||
},
|
||||
});
|
||||
if (timeline.length > 0) {
|
||||
this.persistedHistory = timeline;
|
||||
this.historyPending = true;
|
||||
const timeline = await loadCodexThreadHistoryTimeline({
|
||||
threadId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
requestThread: (threadIdToRead) => {
|
||||
return readCodexThread(client, threadIdToRead);
|
||||
},
|
||||
});
|
||||
this.resetCodexUserMessageTurns();
|
||||
for (const entry of timeline) {
|
||||
if (entry.item.type === "user_message") {
|
||||
this.rememberCodexUserMessageTurn(entry.item.messageId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn({ error }, "Failed to load Codex thread history");
|
||||
}
|
||||
if (timeline.length > 0) {
|
||||
this.persistedHistory = timeline;
|
||||
this.historyPending = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3431,6 +3525,41 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
private rememberCodexUserMessageTurn(messageId: string | null | undefined): boolean {
|
||||
if (typeof messageId !== "string" || messageId.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.userMessageTurnIndexes.has(messageId)) {
|
||||
return false;
|
||||
}
|
||||
this.userMessageTurnIndexes.set(messageId, this.userMessageTurnIds.length);
|
||||
this.userMessageTurnIds.push(messageId);
|
||||
return true;
|
||||
}
|
||||
|
||||
private resetCodexUserMessageTurns(): void {
|
||||
this.userMessageTurnIndexes.clear();
|
||||
this.userMessageTurnIds.length = 0;
|
||||
}
|
||||
|
||||
private truncateCodexUserMessageTurns(numTurns: number): void {
|
||||
if (numTurns <= 0) {
|
||||
return;
|
||||
}
|
||||
this.userMessageTurnIds.length = Math.max(0, this.userMessageTurnIds.length - numTurns);
|
||||
this.userMessageTurnIndexes.clear();
|
||||
this.userMessageTurnIds.forEach((messageId, index) => {
|
||||
this.userMessageTurnIndexes.set(messageId, index);
|
||||
});
|
||||
}
|
||||
|
||||
private codexUserMessageTurns(): CodexUserMessageTurnIndex {
|
||||
return {
|
||||
resolve: (messageId) => this.userMessageTurnIndexes.get(messageId) ?? null,
|
||||
count: () => this.userMessageTurnIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
@@ -3714,6 +3843,35 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("Codex client is not initialized");
|
||||
}
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
|
||||
await revertCodexConversation({
|
||||
client: this.client,
|
||||
threadId: this.currentThreadId,
|
||||
messageId: input.messageId,
|
||||
cwd: this.config.cwd ?? null,
|
||||
model: this.config.model ?? null,
|
||||
serviceTier: this.serviceTier,
|
||||
userMessageTurns: this.codexUserMessageTurns(),
|
||||
setThreadId: async (threadId) => {
|
||||
this.currentThreadId = threadId;
|
||||
this.cachedRuntimeInfo = null;
|
||||
this.persistedHistory = [];
|
||||
this.historyPending = false;
|
||||
await this.loadPersistedHistory();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
if (!this.client || !this.currentThreadId || !this.currentTurnId) return;
|
||||
try {
|
||||
@@ -4059,6 +4217,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private handleNotification(method: string, params: unknown): void {
|
||||
const parsed = CodexNotificationSchema.parse({ method, params });
|
||||
this.traceParsedNotification(method, params, parsed);
|
||||
if (isCodexDeltaNotification(parsed)) {
|
||||
this.handleCodexDeltaNotification(parsed);
|
||||
return;
|
||||
}
|
||||
if (this.handleThreadStateNotification(parsed)) {
|
||||
return;
|
||||
}
|
||||
switch (parsed.kind) {
|
||||
case "thread_started":
|
||||
this.handleThreadStartedNotification(parsed);
|
||||
@@ -4080,15 +4245,6 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
case "token_usage_updated":
|
||||
this.handleTokenUsageUpdatedNotification(parsed);
|
||||
return;
|
||||
case "context_compacted":
|
||||
this.handleContextCompactedNotification(parsed);
|
||||
return;
|
||||
case "agent_message_delta":
|
||||
case "reasoning_delta":
|
||||
case "exec_command_output_delta":
|
||||
case "file_change_output_delta":
|
||||
this.handleCodexDeltaNotification(parsed);
|
||||
return;
|
||||
case "exec_command_started":
|
||||
this.handleExecCommandStartedNotification(parsed);
|
||||
return;
|
||||
@@ -4113,8 +4269,24 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
case "invalid_payload":
|
||||
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
|
||||
return;
|
||||
default:
|
||||
case "unknown_method":
|
||||
this.warnUnknownNotificationMethod(parsed.method, parsed.params);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleThreadStateNotification(parsed: ParsedCodexNotification): boolean {
|
||||
switch (parsed.kind) {
|
||||
case "context_compacted":
|
||||
this.handleContextCompactedNotification(parsed);
|
||||
return true;
|
||||
case "thread_rolled_back":
|
||||
this.handleThreadRolledBackNotification(parsed);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4264,18 +4436,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return Boolean(itemId && this.emittedItemCompletedIds.has(itemId));
|
||||
}
|
||||
|
||||
private handleCodexDeltaNotification(
|
||||
parsed: Extract<
|
||||
ParsedCodexNotification,
|
||||
{
|
||||
kind:
|
||||
| "agent_message_delta"
|
||||
| "reasoning_delta"
|
||||
| "exec_command_output_delta"
|
||||
| "file_change_output_delta";
|
||||
}
|
||||
>,
|
||||
): void {
|
||||
private handleCodexDeltaNotification(parsed: CodexDeltaNotification): void {
|
||||
if (parsed.kind === "agent_message_delta") {
|
||||
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
|
||||
const text = prev + parsed.delta;
|
||||
@@ -4490,6 +4651,19 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private isUserMessageItem(item: { type?: string; [key: string]: unknown }): boolean {
|
||||
return (
|
||||
normalizeCodexThreadItemType(typeof item.type === "string" ? item.type : undefined) ===
|
||||
"userMessage"
|
||||
);
|
||||
}
|
||||
|
||||
private handleThreadRolledBackNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "thread_rolled_back" }>,
|
||||
): void {
|
||||
this.truncateCodexUserMessageTurns(parsed.numTurns);
|
||||
}
|
||||
|
||||
private handleContextCompactedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "context_compacted" }>,
|
||||
): void {
|
||||
@@ -4626,6 +4800,10 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
if (parsed.source === "codex_event") {
|
||||
return;
|
||||
}
|
||||
if (this.isUserMessageItem(parsed.item)) {
|
||||
this.handleUserMessageItem(parsed);
|
||||
return;
|
||||
}
|
||||
if (this.isContextCompactionItem(parsed.item)) {
|
||||
if (this.unpairedCompactionNotificationCompletions > 0) {
|
||||
this.unpairedCompactionNotificationCompletions -= 1;
|
||||
@@ -4769,6 +4947,10 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
if (parsed.source === "codex_event") {
|
||||
return;
|
||||
}
|
||||
if (this.isUserMessageItem(parsed.item)) {
|
||||
this.handleUserMessageItem(parsed);
|
||||
return;
|
||||
}
|
||||
if (this.isContextCompactionItem(parsed.item)) {
|
||||
this.emitEvent({
|
||||
type: "timeline",
|
||||
@@ -4815,6 +4997,31 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private handleUserMessageItem(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
|
||||
): void {
|
||||
const itemId = parsed.item.id;
|
||||
const timelineItem = threadItemToTimeline(parsed.item, {
|
||||
includeUserMessage: true,
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
if (!timelineItem || timelineItem.type !== "user_message") {
|
||||
return;
|
||||
}
|
||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (childSubAgentCallId) {
|
||||
if (itemId) {
|
||||
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||
return;
|
||||
}
|
||||
if (!this.rememberCodexUserMessageTurn(timelineItem.messageId)) {
|
||||
return;
|
||||
}
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
}
|
||||
|
||||
private warnUnknownNotificationMethod(method: string, params: unknown): void {
|
||||
if (this.warnedUnknownNotificationMethods.has(method)) {
|
||||
return;
|
||||
@@ -5510,22 +5717,3 @@ function resolveSkillDescription(skill: Record<string, unknown>): string {
|
||||
}
|
||||
return "Skill";
|
||||
}
|
||||
|
||||
export const __codexAppServerInternals = {
|
||||
buildCodexAppServerEnv,
|
||||
CodexAppServerClient,
|
||||
codexModelSupportsFastMode,
|
||||
CodexAppServerAgentSession,
|
||||
findCodexMicrosoftStoreBinary,
|
||||
findDefaultCodexBinary,
|
||||
formatCodexQuestionPrompts,
|
||||
mapCodexQuestionRequestToToolCall,
|
||||
mapCodexPatchNotificationToToolCall,
|
||||
planStepsToMarkdown,
|
||||
mapCodexPlanToToolCall,
|
||||
listCodexSkills,
|
||||
normalizeCodexOutputSchema,
|
||||
normalizeCodexQuestionPrompts,
|
||||
toAgentUsage,
|
||||
threadItemToTimeline,
|
||||
};
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { rmSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { parseRolloutFile } from "./codex-rollout-timeline.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), "rollout-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("exec_command parsing", () => {
|
||||
test("parses exec_command as Bash tool call with command", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:01.348Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: '{"cmd":"task show cc4ea7d1"}',
|
||||
call_id: "call_MhTWDF2mpM4dhbNmHNt6ikDF",
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls.length).toBe(1);
|
||||
expect(toolCalls[0]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
callId: "call_MhTWDF2mpM4dhbNmHNt6ikDF",
|
||||
detail: { type: "shell", command: "task show cc4ea7d1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("includes function_call_output as tool call output", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:01.348Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: '{"cmd":"echo hello"}',
|
||||
call_id: "call_abc123",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:01.785Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call_output",
|
||||
call_id: "call_abc123",
|
||||
output:
|
||||
"Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nhello",
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls.length).toBe(1);
|
||||
expect(toolCalls[0]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
callId: "call_abc123",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "echo hello",
|
||||
output: "hello",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("maps write_stdin polling calls into terminal interaction tool calls", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:01.348Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: '{"cmd":"npm test"}',
|
||||
call_id: "call_real",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:01.785Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call_output",
|
||||
call_id: "call_real",
|
||||
output:
|
||||
"Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nProcess running with session ID 7144",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:28:16.497Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call",
|
||||
name: "write_stdin",
|
||||
arguments: '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}',
|
||||
call_id: "call_polling",
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls.length).toBe(2);
|
||||
expect(toolCalls[0]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
});
|
||||
expect(toolCalls[1]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "terminal",
|
||||
callId: "terminal-session-7144",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "square_terminal",
|
||||
},
|
||||
metadata: {
|
||||
processId: "7144",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to generic terminal interaction label when command cannot be resolved", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:28:16.497Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call",
|
||||
name: "write_stdin",
|
||||
arguments: '{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}',
|
||||
call_id: "call_polling",
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect(toolCalls[0]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "terminal",
|
||||
callId: "terminal-session-7144",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "square_terminal",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("older shell format", () => {
|
||||
test("parses shell command as Bash tool call", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2025-11-03T14:37:50.400Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call",
|
||||
name: "shell",
|
||||
arguments: '{"command":["bash","-lc","ls -la"],"workdir":"/Users/test/project"}',
|
||||
call_id: "call_shell123",
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls.length).toBe(1);
|
||||
expect(toolCalls[0]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
callId: "call_shell123",
|
||||
detail: { type: "shell", command: "ls -la" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("real rollout file structure", () => {
|
||||
test("parses user message correctly", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.378Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Fix the bug in auth.ts" }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
|
||||
expect(timeline).toContainEqual({
|
||||
type: "user_message",
|
||||
text: "Fix the bug in auth.ts",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses assistant message correctly", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.378Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "I'll fix that for you." }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
|
||||
expect(timeline).toContainEqual({
|
||||
type: "assistant_message",
|
||||
text: "I'll fix that for you.",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses reasoning correctly", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.378Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "reasoning",
|
||||
content: [{ type: "reasoning_text", text: "Let me think about this." }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
|
||||
expect(timeline).toContainEqual({
|
||||
type: "reasoning",
|
||||
text: "Let me think about this.",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses legacy response_item shape using item", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.378Z",
|
||||
type: "response_item",
|
||||
item: {
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: '{"cmd":"echo hello"}',
|
||||
call_id: "call_legacy_1",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:01.785Z",
|
||||
type: "response_item",
|
||||
item: {
|
||||
type: "function_call_output",
|
||||
call_id: "call_legacy_1",
|
||||
output: "hello",
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls.length).toBe(1);
|
||||
expect(toolCalls[0]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
callId: "call_legacy_1",
|
||||
detail: { type: "shell", command: "echo hello", output: "hello" },
|
||||
});
|
||||
});
|
||||
|
||||
test("parses custom_tool_call apply_patch with input/output into editable tool detail", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const patch = [
|
||||
"*** Begin Patch",
|
||||
"*** Add File: src/new-file.ts",
|
||||
"+export const value = 1;",
|
||||
"*** End Patch",
|
||||
].join("\n");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-02-09T10:00:00.000Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "custom_tool_call",
|
||||
name: "apply_patch",
|
||||
call_id: "call_patch_custom_1",
|
||||
input: patch,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-02-09T10:00:01.000Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "custom_tool_call_output",
|
||||
call_id: "call_patch_custom_1",
|
||||
output:
|
||||
'{"output":"Success. Updated the following files:\\nA src/new-file.ts\\n","metadata":{"exit_code":0}}',
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls.length).toBe(1);
|
||||
|
||||
const patchCall = toolCalls[0];
|
||||
expect(patchCall).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "apply_patch",
|
||||
callId: "call_patch_custom_1",
|
||||
status: "completed",
|
||||
});
|
||||
expect(patchCall.detail.type).toBe("edit");
|
||||
if (patchCall.detail.type === "edit") {
|
||||
expect(patchCall.detail.filePath).toBe("src/new-file.ts");
|
||||
expect(patchCall.detail.unifiedDiff).toContain("diff --git");
|
||||
expect(patchCall.detail.unifiedDiff).toContain("+export const value = 1;");
|
||||
expect(patchCall.detail.unifiedDiff).not.toContain("*** Begin Patch");
|
||||
}
|
||||
});
|
||||
|
||||
test("parses legacy event_msg shape using msg", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.378Z",
|
||||
type: "event_msg",
|
||||
msg: {
|
||||
type: "agent_reasoning",
|
||||
text: "thinking",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.378Z",
|
||||
type: "event_msg",
|
||||
msg: {
|
||||
type: "agent_message",
|
||||
message: { role: "assistant", message: "done" },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.378Z",
|
||||
type: "event_msg",
|
||||
msg: {
|
||||
type: "user_message",
|
||||
message: { role: "user", message: "question" },
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
expect(timeline).toContainEqual({ type: "reasoning", text: "thinking" });
|
||||
expect(timeline).toContainEqual({ type: "assistant_message", text: "done" });
|
||||
expect(timeline).toContainEqual({ type: "user_message", text: "question" });
|
||||
});
|
||||
|
||||
test("deduplicates mirrored response_item and event_msg text records", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.000Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "question" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.001Z",
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "user_message",
|
||||
message: "question",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.010Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "reasoning",
|
||||
content: [{ type: "reasoning_text", text: "thinking" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.011Z",
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "agent_reasoning",
|
||||
text: "thinking",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.020Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "answer" }],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.021Z",
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "agent_message",
|
||||
message: "answer",
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
expect(timeline).toEqual([
|
||||
{ type: "user_message", text: "question" },
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "assistant_message", text: "answer" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("complex conversation", () => {
|
||||
test("parses a full conversation with commands and outputs", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
// User message
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:54.378Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "Run npm test" }],
|
||||
},
|
||||
}),
|
||||
// Reasoning
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:08:55.000Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "reasoning",
|
||||
content: [{ type: "reasoning_text", text: "I need to run the tests." }],
|
||||
},
|
||||
}),
|
||||
// Command
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:01.348Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call",
|
||||
name: "exec_command",
|
||||
arguments: '{"cmd":"npm test"}',
|
||||
call_id: "call_test",
|
||||
},
|
||||
}),
|
||||
// Command output
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:05.000Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call_output",
|
||||
call_id: "call_test",
|
||||
output:
|
||||
"Chunk ID: abc\nWall time: 3.5 seconds\nProcess exited with code 0\nOutput:\nAll tests passed!",
|
||||
},
|
||||
}),
|
||||
// Assistant response
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:06.000Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "All tests passed!" }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
|
||||
// Should have: user_message, reasoning, tool_call (with output), assistant_message
|
||||
expect(timeline.length).toBe(4);
|
||||
|
||||
expect(timeline[0]).toMatchObject({ type: "user_message", text: "Run npm test" });
|
||||
expect(timeline[1]).toMatchObject({
|
||||
type: "reasoning",
|
||||
text: "I need to run the tests.",
|
||||
});
|
||||
expect(timeline[2]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "npm test",
|
||||
output: "All tests passed!",
|
||||
},
|
||||
});
|
||||
expect(timeline[3]).toMatchObject({
|
||||
type: "assistant_message",
|
||||
text: "All tests passed!",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,641 +0,0 @@
|
||||
import { promises as fs, type Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import { mapCodexRolloutToolCall } from "./codex/tool-call-mapper.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
|
||||
const MAX_ROLLOUT_SEARCH_DEPTH = 4;
|
||||
|
||||
function resolveCodexSessionRoot(): string | null {
|
||||
if (process.env.CODEX_SESSION_DIR) {
|
||||
return process.env.CODEX_SESSION_DIR;
|
||||
}
|
||||
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
|
||||
return path.join(codexHome, "sessions");
|
||||
}
|
||||
|
||||
async function findRolloutFile(threadId: string, root: string): Promise<string | null> {
|
||||
let currentLevel: { dir: string; depth: number }[] = [{ dir: root, depth: 0 }];
|
||||
while (currentLevel.length > 0) {
|
||||
const readings = await Promise.all(
|
||||
currentLevel.map(async ({ dir, depth }) => {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return { match: null as string | null, next: [] as { dir: string; depth: number }[] };
|
||||
}
|
||||
let match: string | null = null;
|
||||
const next: { dir: string; depth: number }[] = [];
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isFile()) {
|
||||
const matchesThread = entry.name.includes(threadId);
|
||||
const matchesPrefix = entry.name.startsWith("rollout-");
|
||||
const matchesExtension = entry.name.endsWith(".json") || entry.name.endsWith(".jsonl");
|
||||
if (matchesThread && matchesPrefix && matchesExtension && match === null) {
|
||||
match = entryPath;
|
||||
}
|
||||
} else if (entry.isDirectory() && depth < MAX_ROLLOUT_SEARCH_DEPTH) {
|
||||
next.push({ dir: entryPath, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
return { match, next };
|
||||
}),
|
||||
);
|
||||
const firstMatch = readings.find((reading) => reading.match !== null);
|
||||
if (firstMatch?.match) {
|
||||
return firstMatch.match;
|
||||
}
|
||||
currentLevel = readings.flatMap((reading) => reading.next);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const RolloutContentItemSchema = z.union([
|
||||
z.object({ type: z.literal("input_text"), text: z.string() }),
|
||||
z.object({ type: z.literal("output_text"), text: z.string() }),
|
||||
z.object({ type: z.literal("reasoning_text"), text: z.string() }),
|
||||
z.object({ type: z.literal("text"), text: z.string() }),
|
||||
z
|
||||
.object({
|
||||
type: z.string(),
|
||||
text: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
]);
|
||||
|
||||
const RolloutContentArraySchema = z.array(RolloutContentItemSchema);
|
||||
|
||||
function extractContentTextByType(content: unknown, itemType: string): string {
|
||||
const parsed = RolloutContentArraySchema.safeParse(content);
|
||||
if (!parsed.success) {
|
||||
return "";
|
||||
}
|
||||
return parsed.data
|
||||
.filter((item) => item.type === itemType)
|
||||
.map((item) => item.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const RolloutResponseMessagePayloadSchema = z.object({
|
||||
type: z.literal("message"),
|
||||
role: z.enum(["user", "assistant"]).optional(),
|
||||
content: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const RolloutResponseReasoningPayloadSchema = z.object({
|
||||
type: z.literal("reasoning"),
|
||||
content: z.unknown().optional(),
|
||||
summary: z.array(z.object({ text: z.string().optional() })).optional(),
|
||||
text: z.string().optional(),
|
||||
});
|
||||
|
||||
const RolloutResponseFunctionCallPayloadSchema = z.object({
|
||||
type: z.literal("function_call"),
|
||||
name: z.string().optional(),
|
||||
call_id: z.string().optional(),
|
||||
arguments: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const RolloutResponseCustomToolCallPayloadSchema = z
|
||||
.object({
|
||||
type: z.literal("custom_tool_call"),
|
||||
name: z.string().optional(),
|
||||
call_id: z.string().optional(),
|
||||
arguments: z.unknown().optional(),
|
||||
input: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const RolloutResponseFunctionCallOutputPayloadSchema = z
|
||||
.object({
|
||||
type: z.literal("function_call_output"),
|
||||
call_id: z.string().optional(),
|
||||
output: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const RolloutResponseCustomToolCallOutputPayloadSchema = z
|
||||
.object({
|
||||
type: z.literal("custom_tool_call_output"),
|
||||
call_id: z.string().optional(),
|
||||
output: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const RolloutEventAgentReasoningPayloadSchema = z.object({
|
||||
type: z.literal("agent_reasoning"),
|
||||
text: z.string().optional(),
|
||||
});
|
||||
|
||||
const RolloutEventAgentMessagePayloadSchema = z.object({
|
||||
type: z.literal("agent_message"),
|
||||
message: z
|
||||
.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
role: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const RolloutEventUserMessagePayloadSchema = z.object({
|
||||
type: z.literal("user_message"),
|
||||
message: z
|
||||
.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
role: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type RolloutResponseReasoningPayload = z.infer<typeof RolloutResponseReasoningPayloadSchema>;
|
||||
type ParsedRolloutRecord =
|
||||
| { kind: "timeline"; item: AgentTimelineItem }
|
||||
| { kind: "call"; name: string; callId?: string; input?: unknown }
|
||||
| { kind: "output"; callId: string; output: unknown }
|
||||
| { kind: "ignore" };
|
||||
|
||||
const RolloutMessageContentSchema = z.union([
|
||||
z.string().transform((content) => content.trim()),
|
||||
z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
text: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.transform((content) =>
|
||||
content
|
||||
.map((block) => block.text ?? block.message ?? "")
|
||||
.map((text) => text.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim(),
|
||||
),
|
||||
]);
|
||||
|
||||
function extractMessageText(content: unknown): string {
|
||||
const parsed = RolloutMessageContentSchema.safeParse(content);
|
||||
if (!parsed.success) {
|
||||
return "";
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
const RolloutEventMessageTextSchema = z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
message: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((message) => message.message ?? message.text ?? ""),
|
||||
]);
|
||||
|
||||
function extractEventMessageText(message: unknown): string {
|
||||
const parsed = RolloutEventMessageTextSchema.safeParse(message);
|
||||
if (!parsed.success) {
|
||||
return "";
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function isSyntheticRolloutUserMessage(text: string): boolean {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const lower = normalized.toLowerCase();
|
||||
if (lower.startsWith("# agents.md instructions for") && lower.includes("<instructions>")) {
|
||||
return true;
|
||||
}
|
||||
if (lower.startsWith("<environment_context>")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractReasoningText(payload: RolloutResponseReasoningPayload): string {
|
||||
if (Array.isArray(payload.summary)) {
|
||||
const text = payload.summary
|
||||
.map((item) => item.text ?? "")
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
const contentText = extractContentTextByType(payload.content, "reasoning_text");
|
||||
if (contentText) {
|
||||
return contentText;
|
||||
}
|
||||
if (typeof payload.text === "string") {
|
||||
return payload.text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseJsonLikeString(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonLikeValue(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return parseJsonLikeString(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readTerminalSessionId(value: unknown): string | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return nonEmptyString(value.trim());
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapCodexTerminalInteractionToToolCall(params: {
|
||||
processId?: string | undefined;
|
||||
fallbackCallId?: string | undefined;
|
||||
command?: string | undefined;
|
||||
}): Extract<AgentTimelineItem, { type: "tool_call" }> {
|
||||
const processId = nonEmptyString(params.processId);
|
||||
const callId = processId
|
||||
? `terminal-session-${processId}`
|
||||
: (nonEmptyString(params.fallbackCallId) ?? "terminal-interaction");
|
||||
const label = nonEmptyString(params.command);
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
...(label ? { label } : {}),
|
||||
icon: "square_terminal",
|
||||
},
|
||||
metadata: processId ? { processId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerminalCommandBySessionId(
|
||||
parsedRecords: ParsedRolloutRecord[],
|
||||
): Map<string, string> {
|
||||
const outputsByCallId = parsedRecords
|
||||
.filter(
|
||||
(record): record is Extract<ParsedRolloutRecord, { kind: "output" }> =>
|
||||
record.kind === "output",
|
||||
)
|
||||
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
|
||||
|
||||
const commandsBySessionId = new Map<string, string>();
|
||||
for (const record of parsedRecords) {
|
||||
if (record.kind !== "call" || record.name === "write_stdin") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: record.callId ?? null,
|
||||
name: record.name,
|
||||
input: record.input ?? null,
|
||||
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
|
||||
});
|
||||
if (!mapped || mapped.detail.type !== "shell") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionId = extractCodexTerminalSessionId(mapped.detail.output);
|
||||
if (!sessionId) {
|
||||
continue;
|
||||
}
|
||||
commandsBySessionId.set(sessionId, mapped.detail.command);
|
||||
}
|
||||
|
||||
return commandsBySessionId;
|
||||
}
|
||||
|
||||
function readOutputPayloadValue(payload: Record<string, unknown>): unknown {
|
||||
if (payload.output !== undefined) {
|
||||
return parseJsonLikeValue(payload.output);
|
||||
}
|
||||
|
||||
const { type: _type, call_id: _callId, ...rest } = payload;
|
||||
return Object.keys(rest).length > 0 ? rest : undefined;
|
||||
}
|
||||
|
||||
const FunctionCallInputNormalizationSchema = z.union([
|
||||
z
|
||||
.object({ cmd: z.string() })
|
||||
.transform((input) => ({ name: "Bash", input: { command: input.cmd } })),
|
||||
z
|
||||
.object({ command: z.array(z.string()) })
|
||||
.transform((input) => ({ name: "Bash", input: { command: input.command[2] ?? "" } })),
|
||||
z.unknown().transform((input) => ({ name: "unknown", input })),
|
||||
]);
|
||||
|
||||
const RolloutResponseRecordSchema = z.union([
|
||||
RolloutResponseMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractMessageText(payload.content);
|
||||
const itemType = payload.role === "assistant" ? "assistant_message" : "user_message";
|
||||
const shouldEmit =
|
||||
text.length > 0 && (itemType !== "user_message" || !isSyntheticRolloutUserMessage(text));
|
||||
return shouldEmit ? { kind: "timeline", item: { type: itemType, text } } : { kind: "ignore" };
|
||||
}),
|
||||
RolloutResponseReasoningPayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractReasoningText(payload);
|
||||
return text.length > 0
|
||||
? { kind: "timeline", item: { type: "reasoning", text } }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
z
|
||||
.union([RolloutResponseFunctionCallPayloadSchema, RolloutResponseCustomToolCallPayloadSchema])
|
||||
.transform((payload): ParsedRolloutRecord => {
|
||||
const rawName = payload.name ?? "unknown";
|
||||
const rawInput = payload.arguments ?? ("input" in payload ? payload.input : undefined);
|
||||
const parsedArguments = parseJsonLikeValue(rawInput);
|
||||
const normalized =
|
||||
rawName === "exec_command" || rawName === "shell"
|
||||
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
|
||||
: { name: rawName, input: parsedArguments };
|
||||
return {
|
||||
kind: "call",
|
||||
name: normalized.name,
|
||||
callId: payload.call_id,
|
||||
input: normalized.input,
|
||||
};
|
||||
}),
|
||||
z
|
||||
.union([
|
||||
RolloutResponseFunctionCallOutputPayloadSchema,
|
||||
RolloutResponseCustomToolCallOutputPayloadSchema,
|
||||
])
|
||||
.transform((payload): ParsedRolloutRecord => {
|
||||
if (!payload.call_id) {
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
const output = readOutputPayloadValue(payload);
|
||||
return output !== undefined
|
||||
? { kind: "output", callId: payload.call_id, output }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||
]);
|
||||
|
||||
const RolloutEventRecordSchema = z.union([
|
||||
RolloutEventAgentReasoningPayloadSchema.transform(
|
||||
(payload): ParsedRolloutRecord =>
|
||||
payload.text
|
||||
? { kind: "timeline", item: { type: "reasoning", text: payload.text } }
|
||||
: { kind: "ignore" },
|
||||
),
|
||||
RolloutEventAgentMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractEventMessageText(payload.message);
|
||||
return text.length > 0
|
||||
? { kind: "timeline", item: { type: "assistant_message", text } }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
RolloutEventUserMessagePayloadSchema.transform((payload): ParsedRolloutRecord => {
|
||||
const text = extractEventMessageText(payload.message);
|
||||
const shouldEmit = text.length > 0 && !isSyntheticRolloutUserMessage(text);
|
||||
return shouldEmit
|
||||
? { kind: "timeline", item: { type: "user_message", text } }
|
||||
: { kind: "ignore" };
|
||||
}),
|
||||
z.unknown().transform((): ParsedRolloutRecord => ({ kind: "ignore" })),
|
||||
]);
|
||||
|
||||
const RolloutRecordSchema = z
|
||||
.object({
|
||||
type: z.enum(["response_item", "event_msg"]),
|
||||
payload: z.unknown().optional(),
|
||||
item: z.unknown().optional(),
|
||||
msg: z.unknown().optional(),
|
||||
})
|
||||
.transform((entry) =>
|
||||
entry.type === "response_item"
|
||||
? RolloutResponseRecordSchema.parse(entry.payload ?? entry.item)
|
||||
: RolloutEventRecordSchema.parse(entry.payload ?? entry.msg),
|
||||
);
|
||||
|
||||
function parseJsonRolloutTimeline(parsed: unknown): AgentTimelineItem[] | null {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
const items = (parsed as { items?: unknown }).items;
|
||||
if (!Array.isArray(items)) {
|
||||
return null;
|
||||
}
|
||||
const timeline: AgentTimelineItem[] = [];
|
||||
for (const entry of items) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const messagePayloadResult = RolloutResponseMessagePayloadSchema.safeParse(entry);
|
||||
if (messagePayloadResult.success) {
|
||||
const payload = messagePayloadResult.data;
|
||||
const text = extractMessageText(payload.content);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
if (payload.role === "assistant") {
|
||||
timeline.push({ type: "assistant_message", text });
|
||||
} else if (payload.role === "user") {
|
||||
if (!isSyntheticRolloutUserMessage(text)) {
|
||||
timeline.push({ type: "user_message", text });
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const reasoningPayloadResult = RolloutResponseReasoningPayloadSchema.safeParse(entry);
|
||||
if (reasoningPayloadResult.success) {
|
||||
const text = extractReasoningText(reasoningPayloadResult.data);
|
||||
if (text) {
|
||||
timeline.push({ type: "reasoning", text });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return timeline;
|
||||
}
|
||||
|
||||
function timelineTextFingerprint(item: AgentTimelineItem): string | null {
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
case "assistant_message":
|
||||
case "reasoning":
|
||||
return `${item.type}\u0000${item.text}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeMirroredTextTimelineItems(timeline: AgentTimelineItem[]): AgentTimelineItem[] {
|
||||
const deduped: AgentTimelineItem[] = [];
|
||||
for (const item of timeline) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
if (!prev) {
|
||||
deduped.push(item);
|
||||
continue;
|
||||
}
|
||||
const currentFingerprint = timelineTextFingerprint(item);
|
||||
const previousFingerprint = timelineTextFingerprint(prev);
|
||||
if (currentFingerprint && previousFingerprint && currentFingerprint === previousFingerprint) {
|
||||
continue;
|
||||
}
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export async function parseRolloutFile(filePath: string): Promise<AgentTimelineItem[]> {
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
const jsonTimeline = parseJsonRolloutTimeline(parsed);
|
||||
if (jsonTimeline) {
|
||||
return dedupeMirroredTextTimelineItems(jsonTimeline);
|
||||
}
|
||||
} catch {
|
||||
// Fall back to JSONL parsing.
|
||||
}
|
||||
const lines = trimmed
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const parsedRecords = lines
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((record): record is unknown => record !== null)
|
||||
.map((record) => RolloutRecordSchema.safeParse(record))
|
||||
.filter((result): result is { success: true; data: ParsedRolloutRecord } => result.success)
|
||||
.map((result) => result.data);
|
||||
|
||||
const outputsByCallId = parsedRecords
|
||||
.filter(
|
||||
(record): record is Extract<ParsedRolloutRecord, { kind: "output" }> =>
|
||||
record.kind === "output",
|
||||
)
|
||||
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
|
||||
const terminalCommandsBySessionId = buildTerminalCommandBySessionId(parsedRecords);
|
||||
|
||||
const timeline = parsedRecords.flatMap((record): AgentTimelineItem[] => {
|
||||
if (record.kind === "timeline") {
|
||||
return [record.item];
|
||||
}
|
||||
if (record.kind !== "call") {
|
||||
return [];
|
||||
}
|
||||
if (record.name === "write_stdin") {
|
||||
const input =
|
||||
record.input && typeof record.input === "object"
|
||||
? (record.input as { session_id?: unknown; sessionId?: unknown })
|
||||
: null;
|
||||
const sessionId =
|
||||
readTerminalSessionId(input?.session_id) ?? readTerminalSessionId(input?.sessionId);
|
||||
return [
|
||||
mapCodexTerminalInteractionToToolCall({
|
||||
processId: sessionId,
|
||||
fallbackCallId: record.callId,
|
||||
command: sessionId ? terminalCommandsBySessionId.get(sessionId) : undefined,
|
||||
}),
|
||||
];
|
||||
}
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: record.callId ?? null,
|
||||
name: record.name,
|
||||
input: record.input ?? null,
|
||||
output: record.callId ? (outputsByCallId.get(record.callId) ?? null) : null,
|
||||
});
|
||||
return mapped ? [mapped] : [];
|
||||
});
|
||||
return dedupeMirroredTextTimelineItems(timeline);
|
||||
}
|
||||
|
||||
export interface CodexPersistedTimelineOptions {
|
||||
sessionRoot?: string | null;
|
||||
rolloutPath?: string | null;
|
||||
}
|
||||
|
||||
export async function loadCodexPersistedTimeline(
|
||||
sessionId: string,
|
||||
options?: CodexPersistedTimelineOptions,
|
||||
logger?: Logger,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const rolloutPath = options?.rolloutPath ?? null;
|
||||
if (rolloutPath) {
|
||||
try {
|
||||
const stat = await fs.stat(rolloutPath);
|
||||
if (stat.isFile()) {
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
if (timeline.length > 0) {
|
||||
return timeline;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to session root scan.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const preferredRoot = options?.sessionRoot ?? resolveCodexSessionRoot();
|
||||
const fallbackRoot = resolveCodexSessionRoot();
|
||||
let rolloutFile: string | null = null;
|
||||
|
||||
if (preferredRoot) {
|
||||
rolloutFile = await findRolloutFile(sessionId, preferredRoot);
|
||||
}
|
||||
if (!rolloutFile && fallbackRoot && fallbackRoot !== preferredRoot) {
|
||||
rolloutFile = await findRolloutFile(sessionId, fallbackRoot);
|
||||
}
|
||||
if (!rolloutFile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await parseRolloutFile(rolloutFile);
|
||||
} catch (error) {
|
||||
logger?.warn({ err: error, sessionId }, "Failed to load persisted timeline");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { createCodexAppServerChildProcess } from "./test-utils/fake-app-server.js";
|
||||
import {
|
||||
createCodexAppServerChildProcess,
|
||||
createFakeCodexAppServer,
|
||||
} from "./test-utils/fake-app-server.js";
|
||||
import { CodexAppServerClient } from "./app-server-transport.js";
|
||||
|
||||
describe("Codex app-server transport", () => {
|
||||
@@ -25,23 +28,87 @@ describe("Codex app-server transport", () => {
|
||||
"item/tool/requestUserInput",
|
||||
"tool/requestUserInput",
|
||||
])("answers server-initiated %s requests through registered handlers", async (method) => {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const client = new CodexAppServerClient(child, createTestLogger());
|
||||
const codex = createFakeCodexAppServer();
|
||||
const client = new CodexAppServerClient(codex.child, createTestLogger());
|
||||
const handlerCalls: unknown[] = [];
|
||||
client.setRequestHandler(method, async (params) => {
|
||||
handlerCalls.push(params);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const response = new Promise<string>((resolve) => {
|
||||
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
|
||||
});
|
||||
child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
|
||||
const response = codex.nextResponse();
|
||||
codex.child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
|
||||
|
||||
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
|
||||
expect(handlerCalls).toEqual([{}]);
|
||||
child.stdout.end();
|
||||
child.stderr.end();
|
||||
child.stdin.end();
|
||||
codex.child.stdout.end();
|
||||
codex.child.stderr.end();
|
||||
codex.child.stdin.end();
|
||||
});
|
||||
|
||||
test("forks a Codex thread through thread/fork", async () => {
|
||||
const codex = createFakeCodexAppServer({
|
||||
"thread/fork": (params) => ({
|
||||
thread: {
|
||||
id: "forked-thread",
|
||||
sessionId: "forked-session",
|
||||
forkedFromId: (params as { threadId?: string }).threadId,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/workspace/project",
|
||||
runtimeWorkspaceRoots: [],
|
||||
instructionSources: [],
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: null,
|
||||
sandbox: { type: "workspaceWrite", networkAccess: false },
|
||||
activePermissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
}),
|
||||
});
|
||||
const client = new CodexAppServerClient(codex.child, createTestLogger());
|
||||
|
||||
const forked = await client.forkThread({
|
||||
threadId: "source-thread",
|
||||
cwd: "/workspace/project",
|
||||
excludeTurns: true,
|
||||
});
|
||||
|
||||
expect(forked.thread.id).toBe("forked-thread");
|
||||
expect(forked.thread.forkedFromId).toBe("source-thread");
|
||||
codex.assertNoErrors();
|
||||
codex.child.stdout.end();
|
||||
codex.child.stderr.end();
|
||||
codex.child.stdin.end();
|
||||
});
|
||||
|
||||
test("rolls back a Codex thread by N turns", async () => {
|
||||
const codex = createFakeCodexAppServer({
|
||||
"thread/rollback": (params) => {
|
||||
expect(params).toEqual({ threadId: "forked-thread", numTurns: 2 });
|
||||
return {
|
||||
thread: {
|
||||
id: "forked-thread",
|
||||
sessionId: "forked-session",
|
||||
turns: [{ id: "remaining-turn" }],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const client = new CodexAppServerClient(codex.child, createTestLogger());
|
||||
|
||||
const rolledBack = await client.rollbackThread({
|
||||
threadId: "forked-thread",
|
||||
numTurns: 2,
|
||||
});
|
||||
|
||||
expect(rolledBack.thread.id).toBe("forked-thread");
|
||||
expect(rolledBack.thread.turns).toEqual([{ id: "remaining-turn" }]);
|
||||
codex.assertNoErrors();
|
||||
codex.child.stdout.end();
|
||||
codex.child.stderr.end();
|
||||
codex.child.stdin.end();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import readline from "node:readline";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
||||
|
||||
@@ -35,6 +36,81 @@ interface PendingRequest {
|
||||
type RequestHandler = (params: unknown) => unknown;
|
||||
type NotificationHandler = (method: string, params: unknown) => void;
|
||||
|
||||
export interface CodexThreadForkParams {
|
||||
threadId: string;
|
||||
path?: string | null;
|
||||
model?: string | null;
|
||||
modelProvider?: string | null;
|
||||
serviceTier?: string | null;
|
||||
cwd?: string | null;
|
||||
runtimeWorkspaceRoots?: string[] | null;
|
||||
approvalPolicy?: unknown;
|
||||
approvalsReviewer?: unknown;
|
||||
sandbox?: unknown;
|
||||
permissions?: string | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
baseInstructions?: string | null;
|
||||
developerInstructions?: string | null;
|
||||
ephemeral?: boolean;
|
||||
threadSource?: unknown;
|
||||
excludeTurns?: boolean;
|
||||
persistExtendedHistory?: boolean;
|
||||
}
|
||||
|
||||
const CodexThreadForkResponseSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
sessionId: z.string().optional(),
|
||||
forkedFromId: z.string().nullable().optional(),
|
||||
turns: z.array(z.unknown()).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
model: z.string(),
|
||||
modelProvider: z.string(),
|
||||
serviceTier: z.string().nullable(),
|
||||
cwd: z.string(),
|
||||
runtimeWorkspaceRoots: z.array(z.string()).optional().default([]),
|
||||
instructionSources: z.array(z.string()).optional().default([]),
|
||||
approvalPolicy: z.unknown(),
|
||||
approvalsReviewer: z.unknown(),
|
||||
sandbox: z.unknown(),
|
||||
activePermissionProfile: z.unknown().optional(),
|
||||
reasoningEffort: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type CodexThreadForkResponse = z.infer<typeof CodexThreadForkResponseSchema>;
|
||||
|
||||
export function parseCodexThreadForkResponse(response: unknown): CodexThreadForkResponse {
|
||||
return CodexThreadForkResponseSchema.parse(response);
|
||||
}
|
||||
|
||||
export interface CodexThreadRollbackParams {
|
||||
threadId: string;
|
||||
numTurns: number;
|
||||
}
|
||||
|
||||
const CodexThreadRollbackResponseSchema = z
|
||||
.object({
|
||||
thread: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
sessionId: z.string().optional(),
|
||||
forkedFromId: z.string().nullable().optional(),
|
||||
turns: z.array(z.unknown()).optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type CodexThreadRollbackResponse = z.infer<typeof CodexThreadRollbackResponseSchema>;
|
||||
|
||||
export function parseCodexThreadRollbackResponse(response: unknown): CodexThreadRollbackResponse {
|
||||
return CodexThreadRollbackResponseSchema.parse(response);
|
||||
}
|
||||
|
||||
export interface CodexAppServerTraceContext {
|
||||
agentId?: string;
|
||||
sessionId?: string;
|
||||
@@ -156,6 +232,14 @@ export class CodexAppServerClient {
|
||||
});
|
||||
}
|
||||
|
||||
async forkThread(params: CodexThreadForkParams): Promise<CodexThreadForkResponse> {
|
||||
return parseCodexThreadForkResponse(await this.request("thread/fork", params));
|
||||
}
|
||||
|
||||
async rollbackThread(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse> {
|
||||
return parseCodexThreadRollbackResponse(await this.request("thread/rollback", params));
|
||||
}
|
||||
|
||||
notify(method: string, params?: unknown): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type {
|
||||
CodexThreadForkParams,
|
||||
CodexThreadForkResponse,
|
||||
CodexThreadRollbackParams,
|
||||
CodexThreadRollbackResponse,
|
||||
} from "./app-server-transport.js";
|
||||
import {
|
||||
type CodexUserMessageTurnIndex,
|
||||
type CodexRewindClient,
|
||||
revertCodexConversation,
|
||||
} from "./rewind.js";
|
||||
|
||||
class FakeCodex implements CodexRewindClient {
|
||||
readonly recordedForks: CodexThreadForkParams[] = [];
|
||||
readonly recordedRollbacks: CodexThreadRollbackParams[] = [];
|
||||
|
||||
async forkThread(params: CodexThreadForkParams): Promise<CodexThreadForkResponse> {
|
||||
this.recordedForks.push(params);
|
||||
return {
|
||||
thread: {
|
||||
id: "forked-thread",
|
||||
sessionId: "forked-session",
|
||||
forkedFromId: params.threadId,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.4-mini",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/workspace/project",
|
||||
runtimeWorkspaceRoots: [],
|
||||
instructionSources: [],
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: null,
|
||||
sandbox: { type: "workspaceWrite", networkAccess: false },
|
||||
activePermissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
};
|
||||
}
|
||||
|
||||
async rollbackThread(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse> {
|
||||
this.recordedRollbacks.push(params);
|
||||
return {
|
||||
thread: {
|
||||
id: params.threadId,
|
||||
sessionId: "forked-session",
|
||||
forkedFromId: "source-thread",
|
||||
turns: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
request(): Promise<unknown> {
|
||||
throw new Error("FakeCodex uses typed thread methods");
|
||||
}
|
||||
}
|
||||
|
||||
class CodexMessageTurns implements CodexUserMessageTurnIndex {
|
||||
constructor(private readonly indexesByMessageId: Map<string, number>) {}
|
||||
|
||||
resolve(messageId: string): number | null {
|
||||
return this.indexesByMessageId.get(messageId) ?? null;
|
||||
}
|
||||
|
||||
count(): number {
|
||||
return this.indexesByMessageId.size;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Codex Rewind", () => {
|
||||
test("rewinds the conversation by forking the thread and rolling back past the native user message", async () => {
|
||||
const codex = new FakeCodex();
|
||||
const userMessageTurns = new CodexMessageTurns(
|
||||
new Map([
|
||||
["codex-first", 0],
|
||||
["codex-second", 1],
|
||||
]),
|
||||
);
|
||||
let reboundThreadId: string | null = null;
|
||||
|
||||
await revertCodexConversation({
|
||||
client: codex,
|
||||
threadId: "source-thread",
|
||||
messageId: "codex-first",
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
userMessageTurns,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(codex.recordedForks).toEqual([
|
||||
{
|
||||
threadId: "source-thread",
|
||||
cwd: "/workspace/project",
|
||||
model: "gpt-5.4-mini",
|
||||
serviceTier: null,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
},
|
||||
]);
|
||||
expect(codex.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 2 }]);
|
||||
expect(reboundThreadId).toBe("forked-thread");
|
||||
});
|
||||
|
||||
test("rewinds the conversation using native user message ids hydrated from app-server history", async () => {
|
||||
const codex = new FakeCodex();
|
||||
const userMessageTurns = new CodexMessageTurns(
|
||||
new Map([
|
||||
["codex-first", 0],
|
||||
["codex-second", 1],
|
||||
["codex-third", 2],
|
||||
]),
|
||||
);
|
||||
let reboundThreadId: string | null = null;
|
||||
|
||||
await revertCodexConversation({
|
||||
client: codex,
|
||||
threadId: "source-thread",
|
||||
messageId: "codex-second",
|
||||
userMessageTurns,
|
||||
setThreadId: (threadId) => {
|
||||
reboundThreadId = threadId;
|
||||
},
|
||||
});
|
||||
|
||||
expect(codex.recordedRollbacks).toEqual([{ threadId: "forked-thread", numTurns: 2 }]);
|
||||
expect(reboundThreadId).toBe("forked-thread");
|
||||
});
|
||||
|
||||
test("declines to rewind when the user message is not in the Codex thread", async () => {
|
||||
const codex = new FakeCodex();
|
||||
const userMessageTurns = new CodexMessageTurns(new Map([["codex-first", 0]]));
|
||||
|
||||
await expect(
|
||||
revertCodexConversation({
|
||||
client: codex,
|
||||
threadId: "source-thread",
|
||||
messageId: "missing-message",
|
||||
userMessageTurns,
|
||||
setThreadId: () => undefined,
|
||||
}),
|
||||
).rejects.toThrow("Codex could not find user message missing-message");
|
||||
expect(codex.recordedForks).toEqual([]);
|
||||
expect(codex.recordedRollbacks).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type {
|
||||
CodexThreadForkParams,
|
||||
CodexThreadForkResponse,
|
||||
CodexThreadRollbackParams,
|
||||
CodexThreadRollbackResponse,
|
||||
} from "./app-server-transport.js";
|
||||
import {
|
||||
parseCodexThreadForkResponse,
|
||||
parseCodexThreadRollbackResponse,
|
||||
} from "./app-server-transport.js";
|
||||
|
||||
export interface CodexRewindClient {
|
||||
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
|
||||
rollbackThread?(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse>;
|
||||
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface CodexUserMessageTurnIndex {
|
||||
resolve(messageId: string): number | null;
|
||||
count(): number;
|
||||
}
|
||||
|
||||
async function forkCodexThread(
|
||||
client: CodexRewindClient,
|
||||
params: CodexThreadForkParams,
|
||||
): Promise<CodexThreadForkResponse> {
|
||||
if (client.forkThread) {
|
||||
return client.forkThread(params);
|
||||
}
|
||||
return parseCodexThreadForkResponse(await client.request("thread/fork", params));
|
||||
}
|
||||
|
||||
async function rollbackCodexThread(
|
||||
client: CodexRewindClient,
|
||||
params: CodexThreadRollbackParams,
|
||||
): Promise<CodexThreadRollbackResponse> {
|
||||
if (client.rollbackThread) {
|
||||
return client.rollbackThread(params);
|
||||
}
|
||||
return parseCodexThreadRollbackResponse(await client.request("thread/rollback", params));
|
||||
}
|
||||
|
||||
export async function revertCodexConversation(input: {
|
||||
client: CodexRewindClient;
|
||||
threadId: string | null;
|
||||
messageId: string;
|
||||
cwd?: string | null;
|
||||
model?: string | null;
|
||||
serviceTier?: string | null;
|
||||
userMessageTurns: CodexUserMessageTurnIndex;
|
||||
setThreadId: (threadId: string) => void | Promise<void>;
|
||||
}): Promise<void> {
|
||||
if (!input.threadId) {
|
||||
throw new Error("Codex thread is not ready for rewind");
|
||||
}
|
||||
|
||||
const targetTurnIndex = input.userMessageTurns.resolve(input.messageId);
|
||||
if (targetTurnIndex === null) {
|
||||
throw new Error(`Codex could not find user message ${input.messageId} in the current thread`);
|
||||
}
|
||||
|
||||
const currentUserTurnCount = input.userMessageTurns.count();
|
||||
const numTurns = currentUserTurnCount - targetTurnIndex;
|
||||
if (numTurns < 0) {
|
||||
throw new Error(`Codex user message ${input.messageId} is outside the current thread`);
|
||||
}
|
||||
|
||||
// Fork is non-destructive: the old thread file stays on disk and remains
|
||||
// recoverable with `codex resume <old-uuid>` if the rewind target was wrong.
|
||||
const forked = await forkCodexThread(input.client, {
|
||||
threadId: input.threadId,
|
||||
cwd: input.cwd ?? null,
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
excludeTurns: false,
|
||||
persistExtendedHistory: true,
|
||||
});
|
||||
const forkedThreadId = forked.thread.id;
|
||||
|
||||
// Codex rollback is chat-only by design. File edits from rewound turns stay
|
||||
// on disk; a future file primitive would be a separate capability.
|
||||
const rolledBack = await rollbackCodexThread(input.client, {
|
||||
threadId: forkedThreadId,
|
||||
numTurns,
|
||||
});
|
||||
await input.setThreadId(rolledBack.thread.id);
|
||||
}
|
||||
@@ -14,8 +14,11 @@ type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
|
||||
|
||||
export interface FakeCodexAppServer {
|
||||
readonly child: CodexAppServerChildProcess;
|
||||
readonly recordedRollbacks: JsonObject[];
|
||||
assertNoErrors(): void;
|
||||
waitForTurnStart(): Promise<JsonObject>;
|
||||
nextResponse(): Promise<string>;
|
||||
completeTurn(params?: { threadId?: string }): void;
|
||||
requestCommandApproval(params: {
|
||||
itemId: string;
|
||||
threadId: string;
|
||||
@@ -46,6 +49,7 @@ export function createFakeCodexAppServer(
|
||||
handlers: Record<string, FakeCodexAppServerHandler> = {},
|
||||
): FakeCodexAppServer {
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const recordedRollbacks: JsonObject[] = [];
|
||||
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
|
||||
initialize: () => ({}),
|
||||
"collaborationMode/list": () => ({ data: [] }),
|
||||
@@ -65,6 +69,38 @@ export function createFakeCodexAppServer(
|
||||
"thread/loaded/list": () => ({ data: [] }),
|
||||
"thread/resume": () => ({}),
|
||||
"turn/start": () => ({}),
|
||||
"thread/fork": (params) => ({
|
||||
thread: {
|
||||
id: "forked-thread",
|
||||
sessionId: "forked-session",
|
||||
forkedFromId: toJsonObject(params).threadId,
|
||||
turns: [],
|
||||
},
|
||||
model: "gpt-5.4",
|
||||
modelProvider: "openai",
|
||||
serviceTier: null,
|
||||
cwd: "/workspace/project",
|
||||
runtimeWorkspaceRoots: [],
|
||||
instructionSources: [],
|
||||
approvalPolicy: "on-request",
|
||||
approvalsReviewer: null,
|
||||
sandbox: { type: "workspaceWrite", networkAccess: false },
|
||||
activePermissionProfile: null,
|
||||
reasoningEffort: null,
|
||||
}),
|
||||
"thread/rollback": (params) => {
|
||||
const rollback = toJsonObject(params);
|
||||
recordedRollbacks.push(rollback);
|
||||
return {
|
||||
thread: {
|
||||
id: typeof rollback.threadId === "string" ? rollback.threadId : "forked-thread",
|
||||
sessionId: "forked-session",
|
||||
forkedFromId: "thread-1",
|
||||
turns: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
"thread/read": () => ({ thread: { turns: [] } }),
|
||||
...handlers,
|
||||
};
|
||||
const messages: JsonObject[] = [];
|
||||
@@ -161,6 +197,7 @@ export function createFakeCodexAppServer(
|
||||
|
||||
return {
|
||||
child,
|
||||
recordedRollbacks,
|
||||
assertNoErrors() {
|
||||
if (errors.length > 0) {
|
||||
throw errors[0];
|
||||
@@ -173,6 +210,19 @@ export function createFakeCodexAppServer(
|
||||
);
|
||||
return toJsonObject(message.params);
|
||||
},
|
||||
nextResponse() {
|
||||
return new Promise<string>((resolve) => {
|
||||
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
|
||||
});
|
||||
},
|
||||
completeTurn(params = {}) {
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
method: "turn/completed",
|
||||
params: { threadId: params.threadId ?? "thread-1", turn: { status: "completed" } },
|
||||
})}\n`,
|
||||
);
|
||||
},
|
||||
requestCommandApproval(params) {
|
||||
const requestId = nextServerRequestId;
|
||||
nextServerRequestId += 1;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@ const CodexCommandValueSchema = z.union([z.string(), z.array(z.string())]);
|
||||
const CodexToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
|
||||
type CodexToolCallStatus = z.infer<typeof CodexToolCallStatusSchema>;
|
||||
|
||||
const CodexRolloutToolCallParamsSchema = z
|
||||
const CodexToolCallEnvelopeParamsSchema = z
|
||||
.object({
|
||||
callId: z.string().optional().nullable(),
|
||||
name: z.string().min(1),
|
||||
@@ -432,7 +432,7 @@ function asEditTextFields(text: string | undefined): { unifiedDiff?: string; new
|
||||
return { newString: text };
|
||||
}
|
||||
|
||||
function findRolloutEditPatchText(input: Record<string, unknown>): string | undefined {
|
||||
function findToolCallEditPatchText(input: Record<string, unknown>): string | undefined {
|
||||
return (
|
||||
(typeof input.patch === "string" && input.patch) ||
|
||||
(typeof input.diff === "string" && input.diff) ||
|
||||
@@ -443,7 +443,7 @@ function findRolloutEditPatchText(input: Record<string, unknown>): string | unde
|
||||
);
|
||||
}
|
||||
|
||||
function findRolloutEditInputPath(
|
||||
function findToolCallEditInputPath(
|
||||
input: Record<string, unknown>,
|
||||
patchText: string,
|
||||
): string | undefined {
|
||||
@@ -459,14 +459,14 @@ function findRolloutEditInputPath(
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRolloutEditRecordInput(input: Record<string, unknown>): unknown {
|
||||
const candidatePatchText = findRolloutEditPatchText(input);
|
||||
function normalizeToolCallEditRecordInput(input: Record<string, unknown>): unknown {
|
||||
const candidatePatchText = findToolCallEditPatchText(input);
|
||||
if (!candidatePatchText) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const textFields = asEditTextFields(candidatePatchText);
|
||||
const rawPath = findRolloutEditInputPath(input, candidatePatchText);
|
||||
const rawPath = findToolCallEditInputPath(input, candidatePatchText);
|
||||
|
||||
const {
|
||||
patch: _patch,
|
||||
@@ -490,7 +490,7 @@ function normalizeRolloutEditRecordInput(input: Record<string, unknown>): unknow
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeRolloutEditInput(input: unknown): unknown {
|
||||
function normalizeToolCallEditInput(input: unknown): unknown {
|
||||
if (typeof input === "string") {
|
||||
const textFields = asEditTextFields(input);
|
||||
const path = extractPatchPrimaryFilePath(input);
|
||||
@@ -503,7 +503,7 @@ function normalizeRolloutEditInput(input: unknown): unknown {
|
||||
if (!isRecord(input)) {
|
||||
return input;
|
||||
}
|
||||
return normalizeRolloutEditRecordInput(input);
|
||||
return normalizeToolCallEditRecordInput(input);
|
||||
}
|
||||
|
||||
function asEditFileOutputFields(text: string | undefined): { patch?: string; content?: string } {
|
||||
@@ -965,7 +965,7 @@ export function mapCodexToolCallFromThreadItem(
|
||||
return toToolCallFromNormalizedEnvelope(envelope);
|
||||
}
|
||||
|
||||
export function mapCodexRolloutToolCall(params: {
|
||||
export function mapCodexToolCallEnvelope(params: {
|
||||
callId?: string | null;
|
||||
name: string;
|
||||
input?: unknown;
|
||||
@@ -973,7 +973,7 @@ export function mapCodexRolloutToolCall(params: {
|
||||
error?: unknown;
|
||||
cwd?: string | null;
|
||||
}): ToolCallTimelineItem | null {
|
||||
const parsed = CodexRolloutToolCallParamsSchema.safeParse(params);
|
||||
const parsed = CodexToolCallEnvelopeParamsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
@@ -988,7 +988,7 @@ export function mapCodexRolloutToolCall(params: {
|
||||
}
|
||||
const normalizedInput =
|
||||
normalizedName === "apply_patch" || normalizedName === "apply_diff"
|
||||
? normalizeRolloutEditInput(parsed.data.input ?? null)
|
||||
? normalizeToolCallEditInput(parsed.data.input ?? null)
|
||||
: (parsed.data.input ?? null);
|
||||
|
||||
return toToolCallFromNormalizedEnvelope({
|
||||
|
||||
@@ -27,6 +27,9 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
|
||||
|
||||
@@ -42,6 +42,9 @@ const CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: true,
|
||||
supportsRewindBoth: true,
|
||||
};
|
||||
|
||||
const MODELS: AgentModelDefinition[] = [
|
||||
@@ -449,12 +452,17 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||
private modeId: string | null;
|
||||
private modelId: string | null;
|
||||
private readonly rewindError: string | null;
|
||||
|
||||
constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) {
|
||||
this.id = options.sessionId;
|
||||
this.logger = options.logger;
|
||||
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
|
||||
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
|
||||
this.rewindError =
|
||||
typeof options.config.featureValues?.mockRewindError === "string"
|
||||
? options.config.featureValues.mockRewindError
|
||||
: null;
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
@@ -495,6 +503,22 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turnStarted: false,
|
||||
};
|
||||
this.activeTurn = turn;
|
||||
const userMessageId = randomUUID();
|
||||
setTimeout(() => {
|
||||
if (this.activeTurn?.turnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: promptToText(prompt),
|
||||
messageId: userMessageId,
|
||||
},
|
||||
});
|
||||
}, 0);
|
||||
|
||||
const largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
|
||||
const stress = parseAgentStreamStressPrompt(prompt);
|
||||
@@ -609,6 +633,21 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
async revertConversation(_input: { messageId: string }): Promise<void> {
|
||||
this.failConfiguredRewind();
|
||||
this.keepFirstUserMessageHistory();
|
||||
}
|
||||
|
||||
async revertFiles(_input: { messageId: string }): Promise<void> {
|
||||
this.failConfiguredRewind();
|
||||
this.keepFirstUserMessageHistory();
|
||||
}
|
||||
|
||||
async revertBoth(_input: { messageId: string }): Promise<void> {
|
||||
this.failConfiguredRewind();
|
||||
this.keepFirstUserMessageHistory();
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
this.modelId = modelId ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
|
||||
}
|
||||
@@ -620,6 +659,12 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
turn.timer.unref?.();
|
||||
}
|
||||
|
||||
private failConfiguredRewind(): void {
|
||||
if (this.rewindError) {
|
||||
throw new Error(this.rewindError);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleLargePayloadTurn(
|
||||
turn: ActiveTurn,
|
||||
largePayload: LargeAgentStreamPayloadRequest,
|
||||
@@ -941,7 +986,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
this.history.push(event);
|
||||
this.remember(event);
|
||||
for (const listener of this.listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
@@ -951,6 +996,22 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private remember(event: AgentStreamEvent): void {
|
||||
this.history.push(event);
|
||||
}
|
||||
|
||||
private keepFirstUserMessageHistory(): void {
|
||||
const nextHistory: AgentStreamEvent[] = [];
|
||||
for (const event of this.history) {
|
||||
if (event.type === "timeline" && event.item.type === "user_message") {
|
||||
nextHistory.push(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.history.length = 0;
|
||||
this.history.push(...nextHistory);
|
||||
}
|
||||
|
||||
private clearTurnTimer(turn: ActiveTurn): void {
|
||||
if (!turn.timer) {
|
||||
return;
|
||||
|
||||
@@ -937,6 +937,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
data: { revert: undefined },
|
||||
error: undefined,
|
||||
}),
|
||||
messages: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
@@ -1029,6 +1033,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
test("streamHistory omits replay timestamps when OpenCode omits times", async () => {
|
||||
const fakeClient = {
|
||||
session: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
data: { revert: undefined },
|
||||
error: undefined,
|
||||
}),
|
||||
messages: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
type OpenCodeServerAcquisition,
|
||||
} from "./opencode/runtime.js";
|
||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
|
||||
|
||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -75,6 +76,9 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: true,
|
||||
};
|
||||
|
||||
const OPENCODE_BUILD_MODE_ID = "build";
|
||||
@@ -700,6 +704,24 @@ function buildOpenCodePromptParts(
|
||||
return output;
|
||||
}
|
||||
|
||||
function buildOpenCodeUserTimelineText(prompt: AgentPromptInput): string {
|
||||
if (typeof prompt === "string") {
|
||||
return prompt;
|
||||
}
|
||||
return prompt
|
||||
.map((part) => {
|
||||
if (part.type === "text") {
|
||||
return part.text;
|
||||
}
|
||||
if (part.type === "image") {
|
||||
return "[Image]";
|
||||
}
|
||||
return renderPromptAttachmentAsText(part);
|
||||
})
|
||||
.filter((text) => text.trim().length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function collectOpenCodePersistedAgentsFromSdk(
|
||||
client: Pick<OpencodeClient, "experimental" | "session">,
|
||||
options?: ListPersistedAgentsOptions,
|
||||
@@ -845,7 +867,7 @@ async function readOpenCodeSessionMessagesFromSdk(
|
||||
return [];
|
||||
}
|
||||
|
||||
return response.data;
|
||||
return filterOpenCodeRevertedMessages(response.data, session.revert);
|
||||
}
|
||||
|
||||
function buildOpenCodeSessionTimeline(
|
||||
@@ -856,6 +878,20 @@ function buildOpenCodeSessionTimeline(
|
||||
);
|
||||
}
|
||||
|
||||
function filterOpenCodeRevertedMessages(
|
||||
messages: ReadonlyArray<OpenCodeSessionMessage>,
|
||||
revert: OpenCodePersistedSession["revert"] | null | undefined,
|
||||
): OpenCodeSessionMessage[] {
|
||||
if (!revert?.messageID || revert.partID) {
|
||||
return [...messages];
|
||||
}
|
||||
const revertIndex = messages.findIndex((message) => message.info.id === revert.messageID);
|
||||
if (revertIndex < 0) {
|
||||
return [...messages];
|
||||
}
|
||||
return messages.slice(0, revertIndex);
|
||||
}
|
||||
|
||||
function resolveOpenCodePersistedSessionModeId(
|
||||
session: OpenCodePersistedSession,
|
||||
messages: ReadonlyArray<OpenCodeSessionMessage>,
|
||||
@@ -1352,6 +1388,8 @@ export interface OpenCodeEventTranslationState {
|
||||
sessionId: string;
|
||||
cwd?: string;
|
||||
messageRoles: Map<string, OpenCodeMessageRole>;
|
||||
pendingUserMessageText?: string | null;
|
||||
emittedUserMessageIds?: Set<string>;
|
||||
accumulatedUsage: AgentUsage;
|
||||
sessionTotalCostUsd?: number;
|
||||
streamedPartKeys: Set<string>;
|
||||
@@ -1969,6 +2007,10 @@ function appendOpenCodeMessageUpdated(
|
||||
return;
|
||||
}
|
||||
state.messageRoles.set(info.id, info.role);
|
||||
if (info.role === "user") {
|
||||
appendOpenCodeUserMessageUpdated(info, state, events);
|
||||
return;
|
||||
}
|
||||
if (info.role !== "assistant") {
|
||||
return;
|
||||
}
|
||||
@@ -1994,6 +2036,23 @@ function appendOpenCodeMessageUpdated(
|
||||
});
|
||||
}
|
||||
|
||||
function appendOpenCodeUserMessageUpdated(
|
||||
info: Extract<OpenCodeMessage, { role: "user" }>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const text = state.pendingUserMessageText;
|
||||
if (!text || text.trim().length === 0 || state.emittedUserMessageIds?.has(info.id)) {
|
||||
return;
|
||||
}
|
||||
state.emittedUserMessageIds?.add(info.id);
|
||||
events.push({
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "user_message", text, messageId: info.id },
|
||||
});
|
||||
}
|
||||
|
||||
function appendOpenCodeMessagePartUpdated(
|
||||
event: Extract<OpenCodeEvent, { type: "message.part.updated" }>,
|
||||
state: OpenCodeEventTranslationState,
|
||||
@@ -2322,6 +2381,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private mcpSetupPromise: Promise<void> | null = null;
|
||||
/** Tracks the role of each message by ID to distinguish user from assistant messages */
|
||||
private messageRoles = new Map<string, OpenCodeMessageRole>();
|
||||
private pendingUserMessageText: string | null = null;
|
||||
private emittedUserMessageIds = new Set<string>();
|
||||
/** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */
|
||||
private streamedPartKeys = new Set<string>();
|
||||
/** Tracks assistant messages already emitted from structured payloads. */
|
||||
@@ -2432,6 +2493,15 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async revertBoth(input: { messageId: string }): Promise<void> {
|
||||
await revertOpenCodeConversationAndFiles({
|
||||
client: this.client,
|
||||
sessionId: this.sessionId,
|
||||
cwd: this.config.cwd,
|
||||
messageId: input.messageId,
|
||||
});
|
||||
}
|
||||
|
||||
private beginSessionAbort(turnId: string | null, reason: string): Promise<void> {
|
||||
const abortPromise = this.client.session
|
||||
.abort({
|
||||
@@ -2492,6 +2562,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||
|
||||
const parts = buildOpenCodePromptParts(prompt);
|
||||
this.pendingUserMessageText = buildOpenCodeUserTimelineText(prompt);
|
||||
const model = this.parseModel(this.config.model);
|
||||
const thinkingOptionId = this.config.thinkingOptionId;
|
||||
const effectiveVariant = thinkingOptionId ?? undefined;
|
||||
@@ -2856,6 +2927,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
} else {
|
||||
this.runningToolCalls.clear();
|
||||
}
|
||||
this.pendingUserMessageText = null;
|
||||
this.activeForegroundTurnId = null;
|
||||
this.abortController = null;
|
||||
this.notifySubscribers(event, turnId);
|
||||
@@ -2934,6 +3006,10 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
const sessionResponse = await this.client.session.get({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
});
|
||||
const response = await this.client.session.messages({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
@@ -2943,7 +3019,11 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of response.data) {
|
||||
const messages = filterOpenCodeRevertedMessages(
|
||||
response.data,
|
||||
sessionResponse.error ? null : sessionResponse.data?.revert,
|
||||
);
|
||||
for (const message of messages) {
|
||||
for (const event of buildOpenCodeReplayTimelineEvents(message)) {
|
||||
yield event;
|
||||
}
|
||||
@@ -3215,6 +3295,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
sessionId: this.sessionId,
|
||||
cwd: this.config.cwd,
|
||||
messageRoles: this.messageRoles,
|
||||
pendingUserMessageText: this.pendingUserMessageText,
|
||||
emittedUserMessageIds: this.emittedUserMessageIds,
|
||||
accumulatedUsage: this.accumulatedUsage,
|
||||
sessionTotalCostUsd: this.sessionTotalCostUsd,
|
||||
streamedPartKeys: this.streamedPartKeys,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { OpenCodeAgentClient } from "../opencode-agent.js";
|
||||
import { revertOpenCodeConversationAndFiles, type OpenCodeRewindClient } from "./rewind.js";
|
||||
|
||||
function rewindCapabilities(capabilities: OpenCodeAgentClient["capabilities"]) {
|
||||
return {
|
||||
supportsRewindConversation: capabilities.supportsRewindConversation,
|
||||
supportsRewindFiles: capabilities.supportsRewindFiles,
|
||||
supportsRewindBoth: capabilities.supportsRewindBoth,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeOpencodeClient implements OpenCodeRewindClient {
|
||||
readonly recordedReverts: Array<{ sessionID: string; directory: string; messageID: string }> = [];
|
||||
revertResponse: Awaited<ReturnType<OpenCodeRewindClient["session"]["revert"]>> = {};
|
||||
|
||||
readonly session = {
|
||||
revert: async (input: { sessionID: string; directory: string; messageID: string }) => {
|
||||
this.recordedReverts.push(input);
|
||||
return this.revertResponse;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("OpenCode rewind", () => {
|
||||
test("rewinds conversation and files to the OpenCode user message", async () => {
|
||||
const opencode = new FakeOpencodeClient();
|
||||
|
||||
await revertOpenCodeConversationAndFiles({
|
||||
client: opencode,
|
||||
sessionId: "session-1",
|
||||
cwd: "/workspace/project",
|
||||
messageId: "user-message-1",
|
||||
});
|
||||
|
||||
expect(opencode.recordedReverts).toEqual([
|
||||
{ sessionID: "session-1", directory: "/workspace/project", messageID: "user-message-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("surfaces OpenCode revert errors", async () => {
|
||||
const opencode = new FakeOpencodeClient();
|
||||
opencode.revertResponse = { error: { name: "NotFoundError", message: "missing message" } };
|
||||
|
||||
await expect(
|
||||
revertOpenCodeConversationAndFiles({
|
||||
client: opencode,
|
||||
sessionId: "session-1",
|
||||
cwd: "/workspace/project",
|
||||
messageId: "missing-message",
|
||||
}),
|
||||
).rejects.toThrow("missing message");
|
||||
expect(opencode.recordedReverts).toEqual([
|
||||
{ sessionID: "session-1", directory: "/workspace/project", messageID: "missing-message" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("declares only combined rewind capability", () => {
|
||||
const client = new OpenCodeAgentClient(createTestLogger());
|
||||
|
||||
expect(rewindCapabilities(client.capabilities)).toEqual({
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { toDiagnosticErrorMessage } from "../diagnostic-utils.js";
|
||||
|
||||
export interface OpenCodeRewindClient {
|
||||
session: {
|
||||
revert(input: {
|
||||
sessionID: string;
|
||||
directory: string;
|
||||
messageID: string;
|
||||
}): Promise<{ error?: unknown }>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function revertOpenCodeConversationAndFiles(input: {
|
||||
client: OpenCodeRewindClient;
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
messageId: string;
|
||||
}): Promise<void> {
|
||||
// OpenCode keeps unrevert available only until a later prompt triggers its cleanup,
|
||||
// which permanently drops reverted messages. Paseo v1 only exposes revert.
|
||||
const response = await input.client.session.revert({
|
||||
sessionID: input.sessionId,
|
||||
directory: input.cwd,
|
||||
messageID: input.messageId,
|
||||
});
|
||||
if (response.error) {
|
||||
throw new Error(toDiagnosticErrorMessage(response.error));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
@@ -15,12 +13,12 @@ function createClient(pi = new FakePi()): PiRpcAgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
function createClientWithPiAgentDir(agentDir: string): PiRpcAgentClient {
|
||||
return new PiRpcAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: new FakePi(),
|
||||
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
|
||||
});
|
||||
function rewindCapabilities(capabilities: PiRpcAgentSession["capabilities"]) {
|
||||
return {
|
||||
supportsRewindConversation: capabilities.supportsRewindConversation,
|
||||
supportsRewindFiles: capabilities.supportsRewindFiles,
|
||||
supportsRewindBoth: capabilities.supportsRewindBoth,
|
||||
};
|
||||
}
|
||||
|
||||
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
||||
@@ -31,6 +29,17 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
|
||||
};
|
||||
}
|
||||
|
||||
function readUtf8File(pathname: string): string {
|
||||
const fd = openSync(pathname, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(fstatSync(fd).size);
|
||||
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(pi = new FakePi()): Promise<{
|
||||
pi: FakePi;
|
||||
session: PiRpcAgentSession;
|
||||
@@ -116,6 +125,13 @@ class SessionEvents {
|
||||
);
|
||||
}
|
||||
|
||||
nextTimelineEvent(): Promise<Extract<AgentStreamEvent, { type: "timeline" }>> {
|
||||
return this.nextEvent(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline",
|
||||
);
|
||||
}
|
||||
|
||||
private nextEvent<T extends AgentStreamEvent>(
|
||||
predicate: (event: AgentStreamEvent) => event is T,
|
||||
): Promise<T> {
|
||||
@@ -306,6 +322,50 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("emits live user messages with captured Pi tree entry ids", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
fakeSession.capturedUserEntries = [{ id: "entry-user-1", parentId: null, text: "hello" }];
|
||||
await session.startTurn("hello");
|
||||
fakeSession.emit({
|
||||
type: "message_end",
|
||||
message: { role: "user", content: "hello" },
|
||||
});
|
||||
|
||||
await events.nextTimelineEvent();
|
||||
|
||||
expect(events.timelineItems()).toEqual([
|
||||
{ type: "user_message", text: "hello", messageId: "entry-user-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("adds Pi assistant context to generic provider finish errors", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
|
||||
await session.startTurn("write qa");
|
||||
pi.latestSession().finishTurn({
|
||||
role: "assistant",
|
||||
provider: "openrouter",
|
||||
model: "google/gemini-2.5-flash-lite",
|
||||
responseId: "gen-test",
|
||||
stopReason: "error",
|
||||
errorMessage: "Provider finish_reason: error",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "I will use the write tool for qa.txt.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(events.nextTurnFailure()).resolves.toMatchObject({
|
||||
error: expect.stringContaining(
|
||||
'Provider finish_reason: error (stopReason=error, model=openrouter/google/gemini-2.5-flash-lite, responseId=gen-test, partial="I will use the write tool for qa.txt.")',
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
test("resumes by launching Pi with the persisted session file and cwd metadata", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
@@ -314,7 +374,7 @@ describe("PiRpcAgentSession", () => {
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session.jsonl",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: {
|
||||
cwd: "/workspace/project",
|
||||
model: "openrouter/model-a",
|
||||
@@ -324,22 +384,25 @@ describe("PiRpcAgentSession", () => {
|
||||
{},
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
session: "/tmp/native-pi-session.jsonl",
|
||||
argv: [
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--model",
|
||||
"openrouter/model-a",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
"/tmp/native-pi-session.jsonl",
|
||||
],
|
||||
}),
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
const actualLaunch = pi.recordedLaunches[0]!;
|
||||
expect(actualLaunch).toMatchObject({
|
||||
cwd: "/workspace/project",
|
||||
session: "/tmp/native-pi-session",
|
||||
});
|
||||
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||
expect(actualLaunch.argv).toEqual([
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--model",
|
||||
"openrouter/model-a",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
"/tmp/native-pi-session",
|
||||
"--extension",
|
||||
actualLaunch.extensionPaths[0],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -354,21 +417,23 @@ describe("PiRpcAgentSession", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/paseo-pi-rpc-test",
|
||||
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
||||
argv: [
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"--append-system-prompt",
|
||||
"Agent prompt\n\nDaemon prompt",
|
||||
],
|
||||
}),
|
||||
);
|
||||
const actualLaunch = pi.recordedLaunches[0]!;
|
||||
expect(actualLaunch).toMatchObject({
|
||||
cwd: "/tmp/paseo-pi-rpc-test",
|
||||
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
||||
});
|
||||
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||
expect(actualLaunch.argv).toEqual([
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"--append-system-prompt",
|
||||
"Agent prompt\n\nDaemon prompt",
|
||||
"--extension",
|
||||
actualLaunch.extensionPaths[0],
|
||||
]);
|
||||
});
|
||||
|
||||
test("resumes Pi sessions with daemon system prompts appended", async () => {
|
||||
@@ -379,7 +444,7 @@ describe("PiRpcAgentSession", () => {
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session.jsonl",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: {
|
||||
cwd: "/workspace/project",
|
||||
model: "openrouter/model-a",
|
||||
@@ -392,25 +457,28 @@ describe("PiRpcAgentSession", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
session: "/tmp/native-pi-session.jsonl",
|
||||
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
||||
argv: [
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--model",
|
||||
"openrouter/model-a",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
"/tmp/native-pi-session.jsonl",
|
||||
"--append-system-prompt",
|
||||
"Agent prompt\n\nDaemon prompt",
|
||||
],
|
||||
}),
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
const actualLaunch = pi.recordedLaunches[0]!;
|
||||
expect(actualLaunch).toMatchObject({
|
||||
cwd: "/workspace/project",
|
||||
session: "/tmp/native-pi-session",
|
||||
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
||||
});
|
||||
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||
expect(actualLaunch.argv).toEqual([
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--model",
|
||||
"openrouter/model-a",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
"/tmp/native-pi-session",
|
||||
"--append-system-prompt",
|
||||
"Agent prompt\n\nDaemon prompt",
|
||||
"--extension",
|
||||
actualLaunch.extensionPaths[0],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -478,6 +546,27 @@ describe("PiRpcAgentClient", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("rewinds conversation through the Pi tree navigation bridge", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
pi.latestSession().capturedUserEntries = [
|
||||
{ id: "entry-1", parentId: null, text: "first prompt" },
|
||||
{ id: "entry-3", parentId: "entry-2", text: "second prompt" },
|
||||
];
|
||||
|
||||
await session.startTurn("first prompt");
|
||||
pi.latestSession().finishTurn({ role: "assistant", content: [] });
|
||||
await events.nextTurnCompletion();
|
||||
|
||||
await session.revertConversation?.({ messageId: "entry-1" });
|
||||
|
||||
expect(rewindCapabilities(session.capabilities)).toEqual({
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
});
|
||||
expect(pi.latestSession().treeNavigationRequests).toEqual(["entry-1"]);
|
||||
});
|
||||
|
||||
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueCommands([
|
||||
@@ -512,7 +601,8 @@ describe("PiRpcAgentClient", () => {
|
||||
cwd: "/tmp/paseo-pi-rpc-test",
|
||||
argv: ["pi", "--mode", "rpc"],
|
||||
});
|
||||
const actualLaunch = pi.recordedLaunches[1];
|
||||
const actualLaunch = pi.recordedLaunches[1]!;
|
||||
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||
expect(actualLaunch.argv).toEqual([
|
||||
"pi",
|
||||
"--mode",
|
||||
@@ -521,12 +611,14 @@ describe("PiRpcAgentClient", () => {
|
||||
"medium",
|
||||
"--mcp-config",
|
||||
actualLaunch.mcpConfigPath,
|
||||
"--extension",
|
||||
actualLaunch.extensionPaths[0],
|
||||
]);
|
||||
expect(session.capabilities.supportsMcpServers).toBe(true);
|
||||
|
||||
const configPath = actualLaunch.mcpConfigPath;
|
||||
expect(configPath).toEqual(expect.any(String));
|
||||
const injectedConfig = JSON.parse(readFileSync(configPath!, "utf8")) as {
|
||||
const injectedConfig = JSON.parse(readUtf8File(configPath!)) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(injectedConfig).toEqual({
|
||||
@@ -565,54 +657,19 @@ describe("PiRpcAgentClient", () => {
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(2);
|
||||
expect(pi.recordedLaunches[1]?.argv).toEqual(["pi", "--mode", "rpc", "--thinking", "medium"]);
|
||||
expect(pi.recordedLaunches[1]?.mcpConfigPath).toBeUndefined();
|
||||
expect(session.capabilities.supportsMcpServers).toBe(false);
|
||||
});
|
||||
|
||||
test("lists persisted Pi sessions from the configured Pi agent directory", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-client-"));
|
||||
const cwd = path.join(root, "workspace");
|
||||
const agentDir = path.join(root, "agent");
|
||||
const sessionsDir = path.join(agentDir, "sessions", "--workspace--");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "20260101_session.jsonl");
|
||||
writeFileSync(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
message: { role: "user", content: "remember this" },
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
const client = createClientWithPiAgentDir(agentDir);
|
||||
|
||||
await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session",
|
||||
cwd,
|
||||
persistence: {
|
||||
provider: "pi",
|
||||
sessionId: "pi-session",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { provider: "pi", cwd },
|
||||
},
|
||||
timeline: [{ type: "user_message", text: "remember this" }],
|
||||
},
|
||||
const actualLaunch = pi.recordedLaunches[1]!;
|
||||
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||
expect(actualLaunch.argv).toEqual([
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"--extension",
|
||||
actualLaunch.extensionPaths[0],
|
||||
]);
|
||||
expect(actualLaunch.mcpConfigPath).toBeUndefined();
|
||||
expect(session.capabilities.supportsMcpServers).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -41,9 +41,13 @@ import {
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "../diagnostic-utils.js";
|
||||
import { streamPiHistory } from "./history-mapper.js";
|
||||
import {
|
||||
getUserMessageText,
|
||||
streamPiHistory,
|
||||
type PiCapturedUserMessageEntry,
|
||||
} from "./history-mapper.js";
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import { listPiPersistedAgents } from "./session-descriptor.js";
|
||||
import { revertPiConversation } from "./rewind.js";
|
||||
import type { PiRuntime, PiRuntimeSession } from "./runtime.js";
|
||||
import type {
|
||||
PiAgentSessionEvent,
|
||||
@@ -68,6 +72,11 @@ import {
|
||||
const PI_PROVIDER = "pi";
|
||||
const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium";
|
||||
const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi";
|
||||
const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree";
|
||||
const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries";
|
||||
const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE";
|
||||
const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT";
|
||||
const PASEO_PI_EXTENSION_RESULT_TIMEOUT_MS = 10_000;
|
||||
|
||||
const PI_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -76,6 +85,9 @@ const PI_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
const PI_THINKING_OPTIONS: ReadonlyArray<{
|
||||
@@ -149,6 +161,26 @@ interface PiMcpConfigFile {
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
interface PiTempFile {
|
||||
path: string;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
interface PiCapturedEntry extends PiCapturedUserMessageEntry {
|
||||
parentId: string | null;
|
||||
}
|
||||
|
||||
interface PendingPiUserMessage {
|
||||
text: string;
|
||||
turnId: string | undefined;
|
||||
}
|
||||
|
||||
interface PendingExtensionResult {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
function normalizePiModelLabel(label: string): string {
|
||||
return label.trim().replace(/[_\s]+/g, " ");
|
||||
}
|
||||
@@ -359,6 +391,109 @@ function createPiMcpConfigFile(servers: Record<string, McpServerConfig>): PiMcpC
|
||||
};
|
||||
}
|
||||
|
||||
function createPiPaseoExtensionFile(): PiTempFile {
|
||||
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-extension-"));
|
||||
const filePath = join(dir, "paseo-integration.mjs");
|
||||
writeFileSync(
|
||||
filePath,
|
||||
`
|
||||
function decodePayload(encoded) {
|
||||
return JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
||||
}
|
||||
|
||||
function readTextContent(content) {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return content
|
||||
.filter((part) => part && part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => part.text)
|
||||
.join("\\n\\n");
|
||||
}
|
||||
|
||||
function getCapturedUserEntries(ctx) {
|
||||
return ctx.sessionManager
|
||||
.getEntries()
|
||||
.filter((entry) => entry.type === "message" && entry.message?.role === "user")
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
parentId: entry.parentId ?? null,
|
||||
text: readTextContent(entry.message.content),
|
||||
}));
|
||||
}
|
||||
|
||||
function emitEntryCapture(ctx, reason, requestId) {
|
||||
ctx.ui.notify(
|
||||
"${PASEO_PI_ENTRY_CAPTURE_MARKER} " +
|
||||
JSON.stringify({ reason, requestId, entries: getCapturedUserEntries(ctx) }),
|
||||
"info",
|
||||
);
|
||||
}
|
||||
|
||||
function emitCommandResult(ctx, requestId, result) {
|
||||
ctx.ui.notify(
|
||||
"${PASEO_PI_COMMAND_RESULT_MARKER} " + JSON.stringify({ requestId, ...result }),
|
||||
result.ok ? "info" : "error",
|
||||
);
|
||||
}
|
||||
|
||||
export default function paseoIntegration(pi) {
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
emitEntryCapture(ctx, "session_start");
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
emitEntryCapture(ctx, "turn_end");
|
||||
});
|
||||
|
||||
pi.registerCommand("${PASEO_PI_CAPTURE_EXTENSION_COMMAND}", {
|
||||
description: "Internal Paseo entry capture bridge",
|
||||
handler: async (args, ctx) => {
|
||||
const payload = decodePayload(args.trim());
|
||||
emitEntryCapture(ctx, "command", payload.requestId);
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("${PASEO_PI_TREE_EXTENSION_COMMAND}", {
|
||||
description: "Internal Paseo tree navigation bridge",
|
||||
handler: async (args, ctx) => {
|
||||
const payload = decodePayload(args.trim());
|
||||
try {
|
||||
const result = await ctx.navigateTree(payload.targetId, { summarize: false });
|
||||
emitEntryCapture(ctx, "tree_navigation");
|
||||
emitCommandResult(ctx, payload.requestId, { ok: true, result });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
emitCommandResult(ctx, payload.requestId, { ok: false, error: message });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
`.trimStart(),
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
path: filePath,
|
||||
cleanup: () => rmSync(dir, { recursive: true, force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function combineCleanup(cleanups: Array<(() => void) | undefined>): (() => void) | undefined {
|
||||
const activeCleanups = cleanups.filter((cleanup): cleanup is () => void => Boolean(cleanup));
|
||||
if (activeCleanups.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return () => {
|
||||
for (const cleanup of activeCleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean {
|
||||
if (command.source !== "extension" || !/^mcp(?::\d+)?$/.test(command.name)) {
|
||||
return false;
|
||||
@@ -396,11 +531,43 @@ function modelToId(model: PiModel | null | undefined): string | null {
|
||||
return model?.provider && model.id ? `${model.provider}/${model.id}` : null;
|
||||
}
|
||||
|
||||
function piAssistantText(message: Extract<PiAgentMessage, { role: "assistant" }>): string | null {
|
||||
const text = message.content
|
||||
.flatMap((part) => {
|
||||
if (part.type === "text") {
|
||||
return [part.text];
|
||||
}
|
||||
if (part.type === "thinking") {
|
||||
return [part.thinking];
|
||||
}
|
||||
return [];
|
||||
})
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function formatPiErrorMessage(message: Extract<PiAgentMessage, { role: "assistant" }>): string {
|
||||
const headline = message.errorMessage?.trim() || "Pi turn failed";
|
||||
const details = [
|
||||
message.stopReason ? `stopReason=${message.stopReason}` : null,
|
||||
message.provider && message.model ? `model=${message.provider}/${message.model}` : null,
|
||||
message.responseModel ? `responseModel=${message.responseModel}` : null,
|
||||
message.responseId ? `responseId=${message.responseId}` : null,
|
||||
].filter((detail): detail is string => detail !== null);
|
||||
const partialText = piAssistantText(message);
|
||||
if (partialText) {
|
||||
details.push(`partial=${JSON.stringify(partialText.slice(0, 500))}`);
|
||||
}
|
||||
return details.length > 0 ? `${headline} (${details.join(", ")})` : headline;
|
||||
}
|
||||
|
||||
function latestPiErrorMessage(messages: PiAgentMessage[]): string | null {
|
||||
const latestAssistant = messages.findLast((message) => message.role === "assistant");
|
||||
return latestAssistant && "errorMessage" in latestAssistant
|
||||
? (latestAssistant.errorMessage ?? null)
|
||||
: null;
|
||||
if (!latestAssistant || !latestAssistant.errorMessage?.trim()) {
|
||||
return null;
|
||||
}
|
||||
return formatPiErrorMessage(latestAssistant);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -411,6 +578,46 @@ function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function parseExtensionMarkerPayload(
|
||||
message: string,
|
||||
marker: string,
|
||||
): Record<string, unknown> | null {
|
||||
const prefix = `${marker} `;
|
||||
if (!message.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(message.slice(prefix.length)) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCapturedEntries(value: unknown): PiCapturedEntry[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.flatMap((entry): PiCapturedEntry[] => {
|
||||
if (!isRecord(entry)) {
|
||||
return [];
|
||||
}
|
||||
const id = optionalString(entry.id)?.trim();
|
||||
const text = optionalString(entry.text);
|
||||
if (!id || text === undefined) {
|
||||
return [];
|
||||
}
|
||||
const parentId = entry.parentId === null ? null : optionalString(entry.parentId)?.trim();
|
||||
return [
|
||||
{
|
||||
id,
|
||||
parentId: parentId || null,
|
||||
text,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function mapExtensionUiRequestToPermission(
|
||||
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
||||
): AgentPermissionRequest | null {
|
||||
@@ -533,6 +740,12 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private readonly pendingExtensionUiRequests = new Map<string, AgentPermissionRequest>();
|
||||
private activeTurnId: string | null = null;
|
||||
private lastKnownThinkingOptionId: string | null;
|
||||
currentLeafOverrideId: string | null | undefined;
|
||||
private readonly capturedUserEntries: PiCapturedEntry[] = [];
|
||||
private readonly capturedUserEntriesById = new Map<string, PiCapturedEntry>();
|
||||
private readonly seenUserEntryIds = new Set<string>();
|
||||
private readonly pendingUserMessages: PendingPiUserMessage[] = [];
|
||||
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
|
||||
private state: PiSessionState;
|
||||
private closed = false;
|
||||
|
||||
@@ -612,7 +825,12 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield* streamPiHistory(PI_PROVIDER, await this.runtimeSession.getMessages());
|
||||
await this.requestEntryCapture("history");
|
||||
yield* streamPiHistory(
|
||||
PI_PROVIDER,
|
||||
await this.runtimeSession.getMessages(),
|
||||
this.capturedUserEntries,
|
||||
);
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
@@ -683,6 +901,35 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
await this.runtimeSession.abort();
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
if (this.activeTurnId) {
|
||||
throw new Error("Cannot rewind the Pi conversation while a Pi turn is active");
|
||||
}
|
||||
await this.refreshState().catch(() => undefined);
|
||||
await this.requestEntryCapture("rewind");
|
||||
const targetEntry = this.capturedUserEntriesById.get(input.messageId);
|
||||
if (!targetEntry) {
|
||||
throw new Error(`Pi rewind target ${input.messageId} was not found in captured tree entries`);
|
||||
}
|
||||
await revertPiConversation({
|
||||
messageId: input.messageId,
|
||||
navigator: {
|
||||
navigateTree: (treeEntryId) => this.runPiTreeExtensionCommand(treeEntryId),
|
||||
},
|
||||
});
|
||||
// Pi keeps all tree nodes, so selecting the previous leaf later reverses this rewind.
|
||||
this.currentLeafOverrideId = targetEntry.parentId;
|
||||
this.activeToolCalls.clear();
|
||||
}
|
||||
|
||||
private async runPiTreeExtensionCommand(targetId: string): Promise<unknown> {
|
||||
const requestId = randomUUID();
|
||||
const resultPromise = this.waitForExtensionResult(requestId);
|
||||
const payload = Buffer.from(JSON.stringify({ targetId, requestId })).toString("base64url");
|
||||
await this.runtimeSession.prompt(`/${PASEO_PI_TREE_EXTENSION_COMMAND} ${payload}`);
|
||||
return await resultPromise;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
@@ -691,6 +938,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
try {
|
||||
await this.runtimeSession.close();
|
||||
} finally {
|
||||
this.rejectAllExtensionResults(new Error("Pi session closed"));
|
||||
this.cleanup?.();
|
||||
}
|
||||
}
|
||||
@@ -742,9 +990,128 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return this.activeTurnId ?? undefined;
|
||||
}
|
||||
|
||||
private async requestEntryCapture(reason: string): Promise<void> {
|
||||
const requestId = randomUUID();
|
||||
const resultPromise = this.waitForExtensionResult(requestId);
|
||||
const payload = Buffer.from(JSON.stringify({ requestId, reason })).toString("base64url");
|
||||
await this.runtimeSession.prompt(`/${PASEO_PI_CAPTURE_EXTENSION_COMMAND} ${payload}`);
|
||||
await resultPromise;
|
||||
}
|
||||
|
||||
private waitForExtensionResult(requestId: string): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingExtensionResults.delete(requestId);
|
||||
reject(new Error(`Pi extension result timed out for request ${requestId}`));
|
||||
}, PASEO_PI_EXTENSION_RESULT_TIMEOUT_MS);
|
||||
this.pendingExtensionResults.set(requestId, { resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
private resolveExtensionResult(requestId: string, result: unknown): void {
|
||||
const pending = this.pendingExtensionResults.get(requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingExtensionResults.delete(requestId);
|
||||
pending.resolve(result);
|
||||
}
|
||||
|
||||
private rejectExtensionResult(requestId: string, error: Error): void {
|
||||
const pending = this.pendingExtensionResults.get(requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingExtensionResults.delete(requestId);
|
||||
pending.reject(error);
|
||||
}
|
||||
|
||||
private rejectAllExtensionResults(error: Error): void {
|
||||
for (const requestId of this.pendingExtensionResults.keys()) {
|
||||
this.rejectExtensionResult(requestId, error);
|
||||
}
|
||||
}
|
||||
|
||||
private recordCapturedUserEntries(entries: PiCapturedEntry[]): void {
|
||||
const previouslySeenEntryIds = new Set(this.seenUserEntryIds);
|
||||
this.capturedUserEntries.splice(0, this.capturedUserEntries.length, ...entries);
|
||||
this.capturedUserEntriesById.clear();
|
||||
for (const entry of entries) {
|
||||
this.capturedUserEntriesById.set(entry.id, entry);
|
||||
}
|
||||
this.flushPendingUserMessages(previouslySeenEntryIds);
|
||||
for (const entry of entries) {
|
||||
this.seenUserEntryIds.add(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
private flushPendingUserMessages(previouslySeenEntryIds: Set<string>): void {
|
||||
for (let index = 0; index < this.pendingUserMessages.length; index += 1) {
|
||||
const pending = this.pendingUserMessages[index]!;
|
||||
const entry = this.capturedUserEntries.find(
|
||||
(candidate) => !previouslySeenEntryIds.has(candidate.id),
|
||||
);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
previouslySeenEntryIds.add(entry.id);
|
||||
this.pendingUserMessages.splice(index, 1);
|
||||
index -= 1;
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: PI_PROVIDER,
|
||||
turnId: pending.turnId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: pending.text,
|
||||
messageId: entry.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleEntryCaptureMarker(message: string): boolean {
|
||||
const payload = parseExtensionMarkerPayload(message, PASEO_PI_ENTRY_CAPTURE_MARKER);
|
||||
if (!payload) {
|
||||
return false;
|
||||
}
|
||||
const entries = parseCapturedEntries(payload.entries);
|
||||
this.recordCapturedUserEntries(entries);
|
||||
if (typeof payload.requestId === "string") {
|
||||
this.resolveExtensionResult(payload.requestId, entries);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleCommandResultMarker(message: string): boolean {
|
||||
const payload = parseExtensionMarkerPayload(message, PASEO_PI_COMMAND_RESULT_MARKER);
|
||||
if (!payload) {
|
||||
return false;
|
||||
}
|
||||
if (typeof payload.requestId !== "string") {
|
||||
return true;
|
||||
}
|
||||
if (payload.ok === true) {
|
||||
this.resolveExtensionResult(payload.requestId, payload.result);
|
||||
return true;
|
||||
}
|
||||
const error = typeof payload.error === "string" ? payload.error : "Pi extension command failed";
|
||||
this.rejectExtensionResult(payload.requestId, new Error(error));
|
||||
return true;
|
||||
}
|
||||
|
||||
private handleExtensionUiRequest(
|
||||
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
||||
): void {
|
||||
const message = optionalString(event.message);
|
||||
if (event.method === "notify" && message) {
|
||||
if (this.handleEntryCaptureMarker(message) || this.handleCommandResultMarker(message)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const request = mapExtensionUiRequestToPermission(event);
|
||||
if (!request) {
|
||||
return;
|
||||
@@ -772,6 +1139,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private handleProcessExit(error: string): void {
|
||||
this.rejectAllExtensionResults(new Error(error));
|
||||
if (!this.activeTurnId) {
|
||||
return;
|
||||
}
|
||||
@@ -803,6 +1171,11 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
turnId,
|
||||
});
|
||||
return;
|
||||
case "message_start":
|
||||
return;
|
||||
case "message_end":
|
||||
this.handleMessageEnd(event, turnId);
|
||||
return;
|
||||
case "message_update":
|
||||
this.handleMessageUpdate(event, turnId);
|
||||
return;
|
||||
@@ -896,6 +1269,29 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessageEnd(
|
||||
event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
|
||||
turnId: string | undefined,
|
||||
): void {
|
||||
if (event.message.role !== "user") {
|
||||
return;
|
||||
}
|
||||
const text = getUserMessageText(event.message.content);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
this.pendingUserMessages.push({ text, turnId });
|
||||
void this.requestEntryCapture("message_end").catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.emit({
|
||||
type: "turn_failed",
|
||||
provider: PI_PROVIDER,
|
||||
turnId,
|
||||
error: message,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private emitToolCallEvent(
|
||||
toolCallId: string,
|
||||
toolCall: PiTrackedToolCall,
|
||||
@@ -981,6 +1377,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
|
||||
const paseoExtension = createPiPaseoExtensionFile();
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
runtimeSession = await this.runtime.startSession({
|
||||
@@ -994,9 +1391,11 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
),
|
||||
env: launchContext?.env,
|
||||
mcpConfigPath: mcpConfig?.path,
|
||||
extensionPaths: [paseoExtension.path],
|
||||
});
|
||||
} catch (error) {
|
||||
mcpConfig?.cleanup();
|
||||
paseoExtension.cleanup();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -1005,11 +1404,12 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
config,
|
||||
initialState: await runtimeSession.getState(),
|
||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||
cleanup: mcpConfig?.cleanup,
|
||||
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||
});
|
||||
} catch (error) {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
mcpConfig?.cleanup();
|
||||
paseoExtension.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1028,6 +1428,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
|
||||
|
||||
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers);
|
||||
const paseoExtension = createPiPaseoExtensionFile();
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
try {
|
||||
runtimeSession = await this.runtime.startSession({
|
||||
@@ -1040,9 +1441,11 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
resumeConfig.config.daemonAppendSystemPrompt,
|
||||
),
|
||||
mcpConfigPath: mcpConfig?.path,
|
||||
extensionPaths: [paseoExtension.path],
|
||||
});
|
||||
} catch (error) {
|
||||
mcpConfig?.cleanup();
|
||||
paseoExtension.cleanup();
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
@@ -1051,11 +1454,12 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
config: resumeConfig.config,
|
||||
initialState: await runtimeSession.getState(),
|
||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||
cleanup: mcpConfig?.cleanup,
|
||||
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||
});
|
||||
} catch (error) {
|
||||
await runtimeSession.close().catch(() => undefined);
|
||||
mcpConfig?.cleanup();
|
||||
paseoExtension.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1074,12 +1478,9 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
options?: ListPersistedAgentsOptions,
|
||||
_options?: ListPersistedAgentsOptions,
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
return await listPiPersistedAgents({
|
||||
...options,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
|
||||
@@ -2,11 +2,14 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { PiAgentMessage } from "./rpc-types.js";
|
||||
import { streamPiHistory } from "./history-mapper.js";
|
||||
import { streamPiHistory, type PiCapturedUserMessageEntry } from "./history-mapper.js";
|
||||
|
||||
async function collectHistory(messages: PiAgentMessage[]): Promise<AgentStreamEvent[]> {
|
||||
async function collectHistory(
|
||||
messages: PiAgentMessage[],
|
||||
userEntries: PiCapturedUserMessageEntry[] = [],
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamPiHistory("pi", messages)) {
|
||||
for await (const event of streamPiHistory("pi", messages, userEntries)) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
@@ -46,7 +49,6 @@ describe("Pi history mapper", () => {
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "read this\n\nthen answer",
|
||||
messageId: "pi-user-0",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -124,4 +126,44 @@ describe("Pi history mapper", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses Pi tree entry ids for replayed user messages", async () => {
|
||||
await expect(
|
||||
collectHistory(
|
||||
[
|
||||
{ role: "user", content: "first prompt" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "first answer" }] },
|
||||
{ role: "user", content: "second prompt" },
|
||||
],
|
||||
[
|
||||
{ id: "entry-user-1", text: "first prompt" },
|
||||
{ id: "entry-user-2", text: "second prompt" },
|
||||
],
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "first prompt",
|
||||
messageId: "entry-user-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "assistant_message", text: "first answer" },
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "second prompt",
|
||||
messageId: "entry-user-2",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
type PiTrackedToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
|
||||
export interface PiCapturedUserMessageEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function isTextContentBlock(block: unknown): block is PiTextContent {
|
||||
return (
|
||||
typeof block === "object" &&
|
||||
@@ -35,6 +40,7 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon
|
||||
export async function* streamPiHistory(
|
||||
provider: string,
|
||||
messages: PiAgentMessage[],
|
||||
userEntries: readonly PiCapturedUserMessageEntry[] = [],
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
||||
let userIndex = 0;
|
||||
@@ -43,13 +49,14 @@ export async function* streamPiHistory(
|
||||
if (message.role === "user") {
|
||||
const text = getUserMessageText(message.content);
|
||||
if (text) {
|
||||
const userEntry = userEntries[userIndex];
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text,
|
||||
messageId: `pi-user-${userIndex}`,
|
||||
...(userEntry ? { messageId: userEntry.id } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface PiRewindNavigator {
|
||||
navigateTree(targetId: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function revertPiConversation(input: {
|
||||
messageId: string;
|
||||
navigator: PiRewindNavigator;
|
||||
}): Promise<void> {
|
||||
const targetId = input.messageId.trim();
|
||||
if (!targetId) {
|
||||
throw new Error("Pi rewind requires a user message id");
|
||||
}
|
||||
await input.navigator.navigateTree(targetId);
|
||||
}
|
||||
@@ -33,6 +33,10 @@ export type PiAgentMessage =
|
||||
| {
|
||||
role: "assistant";
|
||||
content: PiAssistantContent[];
|
||||
provider?: string;
|
||||
model?: string;
|
||||
responseId?: string;
|
||||
responseModel?: string;
|
||||
errorMessage?: string | null;
|
||||
stopReason?: string;
|
||||
}
|
||||
@@ -129,6 +133,8 @@ export type PiAssistantMessageEvent =
|
||||
export type PiAgentSessionEvent =
|
||||
| { type: "agent_start" }
|
||||
| { type: "turn_start" }
|
||||
| { type: "message_start"; message: PiAgentMessage }
|
||||
| { type: "message_end"; message: PiAgentMessage }
|
||||
| {
|
||||
type: "message_update";
|
||||
message: PiAgentMessage;
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface PiRuntimeLaunch {
|
||||
session?: string;
|
||||
systemPrompt?: string;
|
||||
mcpConfigPath?: string;
|
||||
extensionPaths?: string[];
|
||||
}
|
||||
|
||||
export interface PiStartSessionInput {
|
||||
@@ -27,6 +28,7 @@ export interface PiStartSessionInput {
|
||||
session?: string;
|
||||
systemPrompt?: string;
|
||||
mcpConfigPath?: string;
|
||||
extensionPaths?: string[];
|
||||
}
|
||||
|
||||
export interface PiRuntimeSession {
|
||||
@@ -85,6 +87,9 @@ export function buildPiLaunch(input: {
|
||||
if (input.session.mcpConfigPath) {
|
||||
argv.push("--mcp-config", input.session.mcpConfigPath);
|
||||
}
|
||||
for (const extensionPath of input.session.extensionPaths ?? []) {
|
||||
argv.push("--extension", extensionPath);
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: input.session.cwd,
|
||||
@@ -101,6 +106,7 @@ export function buildPiLaunch(input: {
|
||||
session: input.session.session,
|
||||
systemPrompt,
|
||||
mcpConfigPath: input.session.mcpConfigPath,
|
||||
extensionPaths: input.session.extensionPaths,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { listPiPersistedAgents } from "./session-descriptor.js";
|
||||
|
||||
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||
|
||||
function tempRoot(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-"));
|
||||
}
|
||||
|
||||
function writeJsonl(filePath: string, entries: unknown[]): string {
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function piSession(overrides: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
timestamp?: string;
|
||||
entries?: unknown[];
|
||||
}): unknown[] {
|
||||
return [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: overrides.id,
|
||||
timestamp: overrides.timestamp ?? "2026-01-01T00:00:00.000Z",
|
||||
cwd: overrides.cwd,
|
||||
},
|
||||
...(overrides.entries ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
function message(input: {
|
||||
role: "user" | "assistant";
|
||||
content: unknown;
|
||||
timestamp: string;
|
||||
}): unknown {
|
||||
return {
|
||||
type: "message",
|
||||
id: `entry-${input.timestamp}`,
|
||||
parentId: null,
|
||||
timestamp: input.timestamp,
|
||||
message: {
|
||||
role: input.role,
|
||||
content: input.content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sessionInfo(name: string, timestamp: string): unknown {
|
||||
return {
|
||||
type: "session_info",
|
||||
id: `info-${timestamp}`,
|
||||
parentId: null,
|
||||
timestamp,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
describe("listPiPersistedAgents", () => {
|
||||
test("lists Pi sessions from the configured agent dir", async () => {
|
||||
const root = tempRoot();
|
||||
const cwd = path.join(root, "project");
|
||||
const sessionsDir = path.join(root, "agent", "sessions", "--project--");
|
||||
const sessionFile = writeJsonl(
|
||||
path.join(sessionsDir, "20260101_session-a.jsonl"),
|
||||
piSession({
|
||||
id: "session-a",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "first prompt",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
message({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "answer" }],
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
}),
|
||||
message({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "last prompt" }],
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
}),
|
||||
sessionInfo("Named Pi Session", "2026-01-01T00:00:10.000Z"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd,
|
||||
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors).toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "session-a",
|
||||
cwd,
|
||||
title: "Named Pi Session",
|
||||
lastActivityAt: new Date("2026-01-01T00:00:03.000Z"),
|
||||
persistence: {
|
||||
provider: "pi",
|
||||
sessionId: "session-a",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: {
|
||||
provider: "pi",
|
||||
cwd,
|
||||
},
|
||||
},
|
||||
timeline: [
|
||||
{ type: "user_message", text: "first prompt" },
|
||||
{ type: "user_message", text: "last prompt" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses the runtime session-dir override before Pi settings", async () => {
|
||||
const root = tempRoot();
|
||||
const cwd = path.join(root, "project");
|
||||
const agentDir = path.join(root, "agent");
|
||||
const runtimeSessionsDir = path.join(root, "runtime-sessions");
|
||||
const settingsSessionsDir = path.join(root, "settings-sessions");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(agentDir, "settings.json"),
|
||||
JSON.stringify({ sessionDir: settingsSessionsDir }),
|
||||
"utf8",
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(settingsSessionsDir, "ignored.jsonl"),
|
||||
piSession({ id: "settings-session", cwd }),
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(runtimeSessionsDir, "selected.jsonl"),
|
||||
piSession({
|
||||
id: "runtime-session",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "from runtime dir",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd,
|
||||
runtimeSettings: {
|
||||
env: {
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
PI_CODING_AGENT_SESSION_DIR: runtimeSessionsDir,
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["runtime-session"]);
|
||||
});
|
||||
|
||||
test("resolves project settings sessionDir relative to the requested cwd", async () => {
|
||||
const root = tempRoot();
|
||||
const cwd = path.join(root, "project");
|
||||
const projectSettingsDir = path.join(cwd, ".pi");
|
||||
const sessionsDir = path.join(cwd, "relative-sessions");
|
||||
mkdirSync(projectSettingsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(projectSettingsDir, "settings.json"),
|
||||
JSON.stringify({ sessionDir: "relative-sessions" }),
|
||||
"utf8",
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "relative.jsonl"),
|
||||
piSession({
|
||||
id: "relative-session",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "relative settings",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd,
|
||||
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["relative-session"]);
|
||||
});
|
||||
|
||||
test("matches sessions stored with the real cwd when the requested cwd is symlinked", async () => {
|
||||
const root = tempRoot();
|
||||
const realCwd = path.join(root, "real-project");
|
||||
const linkedCwd = path.join(root, "linked-project");
|
||||
mkdirSync(realCwd, { recursive: true });
|
||||
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
|
||||
const persistedCwd = realpathSync(linkedCwd);
|
||||
const sessionsDir = path.join(root, "agent", "sessions");
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "symlinked.jsonl"),
|
||||
piSession({
|
||||
id: "symlinked-session",
|
||||
cwd: persistedCwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "through symlink",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd: linkedCwd,
|
||||
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["symlinked-session"]);
|
||||
});
|
||||
|
||||
test("filters by cwd, sorts by activity, and ignores invalid session files", async () => {
|
||||
const root = tempRoot();
|
||||
const cwd = path.join(root, "project");
|
||||
const otherCwd = path.join(root, "other");
|
||||
const sessionsDir = path.join(root, "agent", "sessions");
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "older.jsonl"),
|
||||
piSession({
|
||||
id: "older",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "older",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "newer.jsonl"),
|
||||
piSession({
|
||||
id: "newer",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "newer",
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "outside.jsonl"),
|
||||
piSession({
|
||||
id: "outside",
|
||||
cwd: otherCwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "outside",
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
writeFileSync(path.join(sessionsDir, "broken.jsonl"), '{"type":"nope"}\n', "utf8");
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd,
|
||||
limit: 1,
|
||||
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["newer"]);
|
||||
});
|
||||
});
|
||||
@@ -1,372 +0,0 @@
|
||||
import { open, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentTimelineItem,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
|
||||
|
||||
const PI_PROVIDER = "pi";
|
||||
const PI_CONFIG_DIR_NAME = ".pi";
|
||||
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
||||
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
const TAIL_BYTES = 256 * 1024;
|
||||
const FULL_SCAN_LINE_LIMIT = 2_000;
|
||||
|
||||
interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
interface PiSessionHeader {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
interface PiSessionTail {
|
||||
title: string | null;
|
||||
lastActivityAt: Date | null;
|
||||
lastUserMessage: string | null;
|
||||
}
|
||||
|
||||
export async function listPiPersistedAgents(
|
||||
options: PiSessionDescriptorOptions = {},
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
const sessionsDir = await resolvePiSessionsDir(options);
|
||||
const files = await walkJsonlFiles(sessionsDir);
|
||||
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
|
||||
const limit = options.limit ?? 20;
|
||||
const descriptors: PersistedAgentDescriptor[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const descriptor = await readPiSessionDescriptor(file);
|
||||
if (!descriptor) continue;
|
||||
if (matchesCwd && !matchesCwd(descriptor.cwd)) continue;
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
|
||||
return descriptors
|
||||
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise<string> {
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
const baseDir = options.cwd ?? process.cwd();
|
||||
const agentDir = resolvePiAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
|
||||
|
||||
const envSessionDir =
|
||||
options.runtimeSettings?.env?.[PI_SESSION_DIR_ENV] ?? env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir?.trim()) {
|
||||
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
const settingsSessionDir = await readConfiguredSessionDir({
|
||||
agentDir,
|
||||
cwd: options.cwd,
|
||||
});
|
||||
if (settingsSessionDir?.trim()) {
|
||||
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
return path.join(agentDir, "sessions");
|
||||
}
|
||||
|
||||
function resolvePiAgentDir(input: {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
env: NodeJS.ProcessEnv;
|
||||
homeDir: string;
|
||||
}): string {
|
||||
const configured = input.runtimeSettings?.env?.[PI_AGENT_DIR_ENV] ?? input.env[PI_AGENT_DIR_ENV];
|
||||
if (configured?.trim()) {
|
||||
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
|
||||
}
|
||||
return path.join(input.homeDir, PI_CONFIG_DIR_NAME, "agent");
|
||||
}
|
||||
|
||||
async function readConfiguredSessionDir(input: {
|
||||
agentDir: string;
|
||||
cwd: string | undefined;
|
||||
}): Promise<string | null> {
|
||||
const values = await Promise.all([
|
||||
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
|
||||
input.cwd
|
||||
? readSessionDirFromSettings(path.join(input.cwd, PI_CONFIG_DIR_NAME, "settings.json"))
|
||||
: null,
|
||||
]);
|
||||
return values[1] ?? values[0] ?? null;
|
||||
}
|
||||
|
||||
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const sessionDir = Reflect.get(parsed, "sessionDir");
|
||||
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
|
||||
if (value === "~") {
|
||||
return options.homeDir;
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(options.homeDir, value.slice(2));
|
||||
}
|
||||
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(root: string): Promise<string[]> {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return await walkJsonlFiles(entryPath);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
async function readPiSessionDescriptor(filePath: string): Promise<PersistedAgentDescriptor | null> {
|
||||
const firstLine = await readFirstLine(filePath);
|
||||
if (!firstLine) return null;
|
||||
const header = parseSessionHeader(firstLine);
|
||||
if (!header) return null;
|
||||
|
||||
const tail = await readTail(filePath).catch(() => "");
|
||||
const tailInfo = parseSessionTail(tail);
|
||||
const headInfo = await scanSessionHead(filePath);
|
||||
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
|
||||
const lastActivityAt =
|
||||
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
|
||||
const timeline = buildPreviewTimeline({
|
||||
firstUserMessage: headInfo.firstUserMessage,
|
||||
lastUserMessage: tailInfo.lastUserMessage,
|
||||
});
|
||||
|
||||
const persistence: AgentPersistenceHandle = {
|
||||
provider: PI_PROVIDER,
|
||||
sessionId: header.sessionId,
|
||||
nativeHandle: filePath,
|
||||
metadata: {
|
||||
provider: PI_PROVIDER,
|
||||
cwd: header.cwd,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
provider: PI_PROVIDER,
|
||||
sessionId: header.sessionId,
|
||||
cwd: header.cwd,
|
||||
title,
|
||||
lastActivityAt,
|
||||
persistence,
|
||||
timeline,
|
||||
};
|
||||
}
|
||||
|
||||
async function readFirstLine(filePath: string): Promise<string | null> {
|
||||
const handle = await open(filePath, "r").catch(() => null);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const buffer = Buffer.alloc(HEAD_BYTES);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
if (bytesRead <= 0) return null;
|
||||
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
|
||||
const newlineIndex = chunk.indexOf("\n");
|
||||
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTail(filePath: string): Promise<string> {
|
||||
const fileStats = await stat(filePath);
|
||||
const start = Math.max(0, fileStats.size - TAIL_BYTES);
|
||||
const length = fileStats.size - start;
|
||||
const handle = await open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileMtime(filePath: string): Promise<Date | null> {
|
||||
try {
|
||||
return (await stat(filePath)).mtime;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionHeader(firstLine: string): PiSessionHeader | null {
|
||||
const entry = parseJsonRecord(firstLine);
|
||||
if (!entry || entry.type !== "session") return null;
|
||||
const sessionId = typeof entry.id === "string" ? entry.id : null;
|
||||
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
|
||||
if (!sessionId || !cwd) return null;
|
||||
const createdAt = parseDate(entry.timestamp);
|
||||
return { sessionId, cwd, createdAt };
|
||||
}
|
||||
|
||||
function parseSessionTail(tail: string): PiSessionTail {
|
||||
const lines = tail.split(/\r?\n/);
|
||||
let title: string | null = null;
|
||||
let lastActivityAt: Date | null = null;
|
||||
let fallbackTimestamp: Date | null = null;
|
||||
let lastUserMessage: string | null = null;
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const entry = parseJsonRecord(lines[index].trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (!title && entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name);
|
||||
}
|
||||
|
||||
const entryTimestamp = parseDate(entry.timestamp);
|
||||
if (!fallbackTimestamp && entryTimestamp) {
|
||||
fallbackTimestamp = entryTimestamp;
|
||||
}
|
||||
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastActivityAt && entryTimestamp) {
|
||||
lastActivityAt = entryTimestamp;
|
||||
}
|
||||
|
||||
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
|
||||
lastUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
|
||||
if (title && lastActivityAt && lastUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { title, lastActivityAt: lastActivityAt ?? fallbackTimestamp, lastUserMessage };
|
||||
}
|
||||
|
||||
async function scanSessionHead(filePath: string): Promise<{
|
||||
title: string | null;
|
||||
firstUserMessage: string | null;
|
||||
}> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
return { title: null, firstUserMessage: null };
|
||||
}
|
||||
|
||||
let title: string | null = null;
|
||||
let firstUserMessage: string | null = null;
|
||||
let lineCount = 0;
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
lineCount += 1;
|
||||
const entry = parseJsonRecord(rawLine.trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name) ?? title;
|
||||
}
|
||||
|
||||
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
|
||||
if (entry.message.role === "user") {
|
||||
firstUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (title && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { title, firstUserMessage };
|
||||
}
|
||||
|
||||
function buildPreviewTimeline(input: {
|
||||
firstUserMessage: string | null;
|
||||
lastUserMessage: string | null;
|
||||
}): AgentTimelineItem[] {
|
||||
const items: AgentTimelineItem[] = [];
|
||||
if (input.firstUserMessage) {
|
||||
items.push({ type: "user_message", text: input.firstUserMessage });
|
||||
}
|
||||
if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) {
|
||||
items.push({ type: "user_message", text: input.lastUserMessage });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseJsonRecord(line: string): Record<string, unknown> | null {
|
||||
if (!line) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function parseDate(value: unknown): Date | null {
|
||||
if (typeof value !== "string" && typeof value !== "number") {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function extractMessageText(content: unknown): string | null {
|
||||
if (typeof content === "string") {
|
||||
return content.trim() || null;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
const text = content
|
||||
.flatMap((part) =>
|
||||
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
|
||||
)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
return text || null;
|
||||
}
|
||||
@@ -53,6 +53,8 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
|
||||
readonly setThinkingLevelRequests: string[] = [];
|
||||
readonly treeNavigationRequests: string[] = [];
|
||||
capturedUserEntries: Array<{ id: string; parentId: string | null; text: string }> = [];
|
||||
abortRequested = false;
|
||||
readonly canceledExtensionUiRequests: string[] = [];
|
||||
readonly extensionUiResponses: Array<{
|
||||
@@ -77,7 +79,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
sessionFile: launch.session ?? "/tmp/pi-session.jsonl",
|
||||
sessionFile: launch.session ?? "/tmp/pi-session",
|
||||
sessionId: "pi-session-1",
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
@@ -96,6 +98,8 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<void> {
|
||||
this.prompts.push({ message, imageCount: images?.length ?? 0 });
|
||||
this.handleTreeNavigationCommand(message);
|
||||
this.handleEntryCaptureCommand(message);
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
@@ -158,4 +162,62 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
this.messages = [...this.messages, message];
|
||||
this.emit({ type: "agent_end", messages: this.messages });
|
||||
}
|
||||
|
||||
private handleTreeNavigationCommand(message: string): void {
|
||||
const prefix = "/paseo_tree ";
|
||||
if (!message.startsWith(prefix)) {
|
||||
return;
|
||||
}
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(message.slice(prefix.length), "base64url").toString("utf8"),
|
||||
) as { targetId?: unknown; requestId?: unknown };
|
||||
if (typeof payload.targetId !== "string" || typeof payload.requestId !== "string") {
|
||||
return;
|
||||
}
|
||||
this.treeNavigationRequests.push(payload.targetId);
|
||||
this.emitEntryCapture(undefined, "tree_navigation");
|
||||
this.emitExtensionCommandResult(payload.requestId, { ok: true, result: {} });
|
||||
}
|
||||
|
||||
private handleEntryCaptureCommand(message: string): void {
|
||||
const prefix = "/paseo_capture_entries ";
|
||||
if (!message.startsWith(prefix)) {
|
||||
return;
|
||||
}
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(message.slice(prefix.length), "base64url").toString("utf8"),
|
||||
) as { requestId?: unknown; reason?: unknown };
|
||||
if (typeof payload.requestId !== "string") {
|
||||
return;
|
||||
}
|
||||
this.emitEntryCapture(
|
||||
payload.requestId,
|
||||
typeof payload.reason === "string" ? payload.reason : "command",
|
||||
);
|
||||
}
|
||||
|
||||
emitEntryCapture(requestId?: string, reason = "test"): void {
|
||||
this.emit({
|
||||
type: "extension_ui_request",
|
||||
id: `capture-${requestId ?? reason}`,
|
||||
method: "notify",
|
||||
message: `PASEO_ENTRY_CAPTURE ${JSON.stringify({
|
||||
reason,
|
||||
requestId,
|
||||
entries: this.capturedUserEntries,
|
||||
})}`,
|
||||
});
|
||||
}
|
||||
|
||||
private emitExtensionCommandResult(
|
||||
requestId: string,
|
||||
result: { ok: true; result: unknown } | { ok: false; error: string },
|
||||
): void {
|
||||
this.emit({
|
||||
type: "extension_ui_request",
|
||||
id: `command-${requestId}`,
|
||||
method: "notify",
|
||||
message: `PASEO_COMMAND_RESULT ${JSON.stringify({ requestId, ...result })}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { AgentManager } from "../agent-manager.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
|
||||
import { ClaudeAgentClient } from "./claude/agent.js";
|
||||
import { __codexAppServerInternals, CodexAppServerAgentClient } from "./codex-app-server-agent.js";
|
||||
import { CodexAppServerAgentClient, findDefaultCodexBinary } from "./codex-app-server-agent.js";
|
||||
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
|
||||
const originalEnv = {
|
||||
@@ -88,7 +88,7 @@ describe("default provider availability", () => {
|
||||
try {
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
|
||||
await expect(__codexAppServerInternals.findDefaultCodexBinary()).resolves.toBe(codexExe);
|
||||
await expect(findDefaultCodexBinary()).resolves.toBe(codexExe);
|
||||
await expect(client.isAvailable()).resolves.toBe(true);
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ListModelsOptions,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { FakeRewindSession, REWIND_TEST_CAPABILITIES } from "./test-rewind-session.js";
|
||||
|
||||
class FakeRewindClient implements AgentClient {
|
||||
readonly provider = "claude";
|
||||
readonly capabilities = REWIND_TEST_CAPABILITIES;
|
||||
|
||||
constructor(readonly session: FakeRewindSession) {}
|
||||
|
||||
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async listModels(_options: ListModelsOptions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class RewindHistoryGate {
|
||||
private gate: Promise<void> | null = null;
|
||||
private releaseGate: (() => void) | null = null;
|
||||
|
||||
hold(): void {
|
||||
this.gate = new Promise<void>((resolve) => {
|
||||
this.releaseGate = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
this.releaseGate?.();
|
||||
this.releaseGate = null;
|
||||
this.gate = null;
|
||||
}
|
||||
|
||||
async wait(): Promise<void> {
|
||||
await this.gate;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRewindHarness(options: { historyGate?: RewindHistoryGate } = {}) {
|
||||
const session = new FakeRewindSession(options.historyGate?.wait.bind(options.historyGate));
|
||||
const manager = new AgentManager({
|
||||
clients: { claude: new FakeRewindClient(session) },
|
||||
logger: createTestLogger(),
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000901",
|
||||
});
|
||||
const agent = await manager.createAgent({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
return { manager, session, agentId: agent.id };
|
||||
}
|
||||
|
||||
describe("AgentManager rewind", () => {
|
||||
test("rewinds the conversation and rehydrates the timeline", async () => {
|
||||
const { manager, session, agentId } = await createRewindHarness();
|
||||
|
||||
await manager.rewind(agentId, "message-1", "conversation");
|
||||
|
||||
expect(session.recordedRewinds).toEqual([{ mode: "conversation", messageId: "message-1" }]);
|
||||
expect(session.historyReadCount).toBe(1);
|
||||
expect(manager.fetchTimeline(agentId, { limit: 0 }).rows.map((row) => row.item)).toEqual([
|
||||
{ type: "user_message", text: "before", messageId: "message-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rewinds files without rehydrating the conversation timeline", async () => {
|
||||
const { manager, session, agentId } = await createRewindHarness();
|
||||
|
||||
await manager.rewind(agentId, "message-1", "files");
|
||||
|
||||
expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "message-1" }]);
|
||||
expect(session.historyReadCount).toBe(0);
|
||||
});
|
||||
|
||||
test("aborts an in-flight turn before rewinding", async () => {
|
||||
const { manager, session, agentId } = await createRewindHarness();
|
||||
const run = manager.streamAgent(agentId, "keep working");
|
||||
await run.next();
|
||||
|
||||
await manager.rewind(agentId, "message-1", "files");
|
||||
|
||||
expect(session.aborted).toBe(true);
|
||||
expect(session.recordedRewinds).toEqual([{ mode: "files", messageId: "message-1" }]);
|
||||
});
|
||||
|
||||
test("blocks new prompts until the rehydrate epoch broadcasts", async () => {
|
||||
const historyGate = new RewindHistoryGate();
|
||||
historyGate.hold();
|
||||
const { manager, agentId } = await createRewindHarness({ historyGate });
|
||||
|
||||
const rewind = manager.rewind(agentId, "message-1", "both");
|
||||
|
||||
expect(() => manager.streamAgent(agentId, "too early")).toThrow(
|
||||
"Agent 00000000-0000-4000-8000-000000000901 already has an active run",
|
||||
);
|
||||
|
||||
historyGate.release();
|
||||
await rewind;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { AgentSession } from "../agent-sdk-types.js";
|
||||
|
||||
export type RewindMode = "conversation" | "files" | "both";
|
||||
|
||||
export class RewindCapabilityError extends Error {
|
||||
constructor(mode: RewindMode) {
|
||||
super(`Provider does not support rewinding ${mode}`);
|
||||
this.name = "RewindCapabilityError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function invokeRewindCapability(
|
||||
session: AgentSession,
|
||||
input: { messageId: string; mode: RewindMode },
|
||||
): Promise<void> {
|
||||
switch (input.mode) {
|
||||
case "conversation":
|
||||
if (!session.capabilities.supportsRewindConversation || !session.revertConversation) {
|
||||
throw new RewindCapabilityError(input.mode);
|
||||
}
|
||||
await session.revertConversation({ messageId: input.messageId });
|
||||
return;
|
||||
case "files":
|
||||
if (!session.capabilities.supportsRewindFiles || !session.revertFiles) {
|
||||
throw new RewindCapabilityError(input.mode);
|
||||
}
|
||||
await session.revertFiles({ messageId: input.messageId });
|
||||
return;
|
||||
case "both":
|
||||
if (!session.capabilities.supportsRewindBoth || !session.revertBoth) {
|
||||
throw new RewindCapabilityError(input.mode);
|
||||
}
|
||||
await session.revertBoth({ messageId: input.messageId });
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
AgentProvider,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
export const REWIND_TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: true,
|
||||
supportsRewindBoth: true,
|
||||
};
|
||||
|
||||
export interface RecordedRewind {
|
||||
mode: "conversation" | "files" | "both";
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
export class FakeRewindSession implements AgentSession {
|
||||
readonly provider: AgentProvider = "claude";
|
||||
readonly id = "fake-rewind-session";
|
||||
readonly capabilities = REWIND_TEST_CAPABILITIES;
|
||||
readonly recordedRewinds: RecordedRewind[] = [];
|
||||
aborted = false;
|
||||
historyReadCount = 0;
|
||||
history: AgentTimelineItem[] = [{ type: "user_message", text: "before", messageId: "message-1" }];
|
||||
|
||||
private subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private activeTurnId: string | null = null;
|
||||
|
||||
constructor(private readonly waitBeforeHistory?: () => Promise<void>) {}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return { sessionId: this.id, finalText: "", timeline: [] };
|
||||
}
|
||||
|
||||
async startTurn(): Promise<{ turnId: string }> {
|
||||
this.aborted = false;
|
||||
this.activeTurnId = "turn-1";
|
||||
queueMicrotask(() => {
|
||||
this.emit({ type: "turn_started", provider: this.provider, turnId: "turn-1" });
|
||||
});
|
||||
return { turnId: "turn-1" };
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => this.subscribers.delete(callback);
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
this.historyReadCount += 1;
|
||||
await this.waitBeforeHistory?.();
|
||||
for (const item of this.history) {
|
||||
yield { type: "timeline", provider: this.provider, item };
|
||||
}
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return { provider: this.provider, sessionId: this.id };
|
||||
}
|
||||
|
||||
async getAvailableModes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async setMode(): Promise<void> {}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
_requestId: string,
|
||||
_response: AgentPermissionResponse,
|
||||
): Promise<void> {}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle {
|
||||
return { provider: this.provider, sessionId: this.id };
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
this.aborted = true;
|
||||
if (this.activeTurnId) {
|
||||
const turnId = this.activeTurnId;
|
||||
this.activeTurnId = null;
|
||||
this.emit({ type: "turn_canceled", provider: this.provider, reason: "interrupted", turnId });
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
this.recordedRewinds.push({ mode: "conversation", messageId: input.messageId });
|
||||
}
|
||||
|
||||
async revertFiles(input: { messageId: string }): Promise<void> {
|
||||
this.recordedRewinds.push({ mode: "files", messageId: input.messageId });
|
||||
}
|
||||
|
||||
async revertBoth(input: { messageId: string }): Promise<void> {
|
||||
this.recordedRewinds.push({ mode: "both", messageId: input.messageId });
|
||||
}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig } from "./agent-configs.js";
|
||||
import {
|
||||
closeRewindSession,
|
||||
fetchTimelineItems,
|
||||
fileExists,
|
||||
tmpRewindCwd,
|
||||
userMessageIdForToken,
|
||||
} from "./test-utils/rewind-helpers.js";
|
||||
|
||||
interface ClaudeRewindHarness {
|
||||
client: DaemonClient;
|
||||
daemon: TestPaseoDaemon;
|
||||
}
|
||||
|
||||
interface ClaudeRewindSession {
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface ClaudeTurnSpec {
|
||||
index: 1 | 2 | 3;
|
||||
promptToken: string;
|
||||
doneToken: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface RewindCase {
|
||||
name: string;
|
||||
turnCount: 1 | 2 | 3;
|
||||
rewindTurn: 1 | 2 | 3;
|
||||
mode: "both" | "conversation" | "files";
|
||||
}
|
||||
|
||||
const TURN_TIMEOUT_MS = 180_000;
|
||||
|
||||
const MATRIX: RewindCase[] = [
|
||||
{
|
||||
name: "single-turn session, rewind only user message, both",
|
||||
turnCount: 1,
|
||||
rewindTurn: 1,
|
||||
mode: "both",
|
||||
},
|
||||
{ name: "two-turn session, rewind user #1, both", turnCount: 2, rewindTurn: 1, mode: "both" },
|
||||
{ name: "two-turn session, rewind user #2, both", turnCount: 2, rewindTurn: 2, mode: "both" },
|
||||
{ name: "three-turn session, rewind user #1, both", turnCount: 3, rewindTurn: 1, mode: "both" },
|
||||
{ name: "three-turn session, rewind user #2, both", turnCount: 3, rewindTurn: 2, mode: "both" },
|
||||
{ name: "three-turn session, rewind user #3, both", turnCount: 3, rewindTurn: 3, mode: "both" },
|
||||
{
|
||||
name: "single-turn session, rewind only user message, conversation",
|
||||
turnCount: 1,
|
||||
rewindTurn: 1,
|
||||
mode: "conversation",
|
||||
},
|
||||
{
|
||||
name: "two-turn session, rewind user #1, conversation",
|
||||
turnCount: 2,
|
||||
rewindTurn: 1,
|
||||
mode: "conversation",
|
||||
},
|
||||
{
|
||||
name: "two-turn session, rewind user #2, conversation",
|
||||
turnCount: 2,
|
||||
rewindTurn: 2,
|
||||
mode: "conversation",
|
||||
},
|
||||
{
|
||||
name: "three-turn session, rewind user #1, conversation",
|
||||
turnCount: 3,
|
||||
rewindTurn: 1,
|
||||
mode: "conversation",
|
||||
},
|
||||
{
|
||||
name: "three-turn session, rewind user #2, conversation",
|
||||
turnCount: 3,
|
||||
rewindTurn: 2,
|
||||
mode: "conversation",
|
||||
},
|
||||
{
|
||||
name: "three-turn session, rewind user #3, conversation",
|
||||
turnCount: 3,
|
||||
rewindTurn: 3,
|
||||
mode: "conversation",
|
||||
},
|
||||
{
|
||||
name: "single-turn session, rewind only user message, files",
|
||||
turnCount: 1,
|
||||
rewindTurn: 1,
|
||||
mode: "files",
|
||||
},
|
||||
{ name: "two-turn session, rewind user #1, files", turnCount: 2, rewindTurn: 1, mode: "files" },
|
||||
{ name: "two-turn session, rewind user #2, files", turnCount: 2, rewindTurn: 2, mode: "files" },
|
||||
{ name: "three-turn session, rewind user #1, files", turnCount: 3, rewindTurn: 1, mode: "files" },
|
||||
{ name: "three-turn session, rewind user #2, files", turnCount: 3, rewindTurn: 2, mode: "files" },
|
||||
{ name: "three-turn session, rewind user #3, files", turnCount: 3, rewindTurn: 3, mode: "files" },
|
||||
];
|
||||
|
||||
async function launchClaudeRewindSession(
|
||||
harness: ClaudeRewindHarness,
|
||||
title: string,
|
||||
): Promise<ClaudeRewindSession> {
|
||||
const cwd = tmpRewindCwd("daemon-real-claude-rewind-");
|
||||
await writeFile(path.join(cwd, "baseline.txt"), "BASE\n", "utf8");
|
||||
|
||||
const agent = await harness.client.createAgent({
|
||||
cwd,
|
||||
title,
|
||||
...getFullAccessConfig("claude"),
|
||||
});
|
||||
|
||||
return { agentId: agent.id, cwd };
|
||||
}
|
||||
|
||||
async function closeClaudeRewindSession(session: ClaudeRewindSession): Promise<void> {
|
||||
closeRewindSession(session);
|
||||
}
|
||||
|
||||
function buildTurns(scenario: RewindCase): ClaudeTurnSpec[] {
|
||||
const prefix = `${scenario.mode.toUpperCase()}_${scenario.turnCount}_${scenario.rewindTurn}`;
|
||||
return Array.from({ length: scenario.turnCount }, (_, offset) => {
|
||||
const index = (offset + 1) as 1 | 2 | 3;
|
||||
return {
|
||||
index,
|
||||
promptToken: `PASEO_RW_${prefix}_T${index}`,
|
||||
doneToken: `PASEO_RW_${prefix}_T${index}_DONE`,
|
||||
fileName: `turn-${index}.txt`,
|
||||
content: `turn ${index} preserved content\n`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function editPrompt(turn: ClaudeTurnSpec): string {
|
||||
return [
|
||||
`${turn.promptToken}.`,
|
||||
`Use the Write tool, not Bash, to create ${turn.fileName} with exactly:`,
|
||||
"```",
|
||||
turn.content.trimEnd(),
|
||||
"```",
|
||||
`When the file is saved, reply exactly: ${turn.doneToken}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function sendClaudeWriteTurn(
|
||||
harness: ClaudeRewindHarness,
|
||||
session: ClaudeRewindSession,
|
||||
turn: ClaudeTurnSpec,
|
||||
): Promise<void> {
|
||||
await harness.client.sendMessage(session.agentId, editPrompt(turn));
|
||||
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
await expect(fileExists(path.join(session.cwd, turn.fileName))).resolves.toBe(true);
|
||||
}
|
||||
|
||||
async function sendClaudeReplyTurn(
|
||||
harness: ClaudeRewindHarness,
|
||||
session: ClaudeRewindSession,
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
await harness.client.sendMessage(session.agentId, prompt);
|
||||
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
}
|
||||
|
||||
async function runtimeSessionId(
|
||||
harness: ClaudeRewindHarness,
|
||||
session: ClaudeRewindSession,
|
||||
): Promise<string | null> {
|
||||
const snapshot = await harness.client.fetchAgent(session.agentId);
|
||||
return snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId ?? null;
|
||||
}
|
||||
|
||||
function roleItems(items: AgentTimelineItem[], role: "user_message" | "assistant_message") {
|
||||
return items.filter((item) => item.type === role);
|
||||
}
|
||||
|
||||
function expectSessionId(value: string | null): asserts value is string {
|
||||
expect(value).toMatch(/^[a-f0-9-]{36}$/);
|
||||
}
|
||||
|
||||
async function createdFilesAtCwd(session: ClaudeRewindSession): Promise<string[]> {
|
||||
const entries = await readdir(session.cwd);
|
||||
return entries.filter((name) => name === "baseline.txt" || /^turn-\d+\.txt$/u.test(name)).sort();
|
||||
}
|
||||
|
||||
async function assertFiles(
|
||||
session: ClaudeRewindSession,
|
||||
expectedPresent: ClaudeTurnSpec[],
|
||||
): Promise<void> {
|
||||
await expect(createdFilesAtCwd(session)).resolves.toEqual(
|
||||
["baseline.txt", ...expectedPresent.map((turn) => turn.fileName)].sort(),
|
||||
);
|
||||
}
|
||||
|
||||
function assertTimeline(items: AgentTimelineItem[], expectedKept: ClaudeTurnSpec[]): void {
|
||||
const userItems = roleItems(items, "user_message");
|
||||
const assistantItems = roleItems(items, "assistant_message");
|
||||
|
||||
expect(userItems).toHaveLength(expectedKept.length);
|
||||
expect(userItems.map((item) => item.text)).toEqual(expectedKept.map(editPrompt));
|
||||
expect(assistantItems).toHaveLength(expectedKept.length);
|
||||
}
|
||||
|
||||
describe("daemon E2E (real claude) - rewind", () => {
|
||||
let harness: ClaudeRewindHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "claude-rewind-real" } });
|
||||
harness = { client, daemon };
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.client.close().catch(() => undefined);
|
||||
await harness?.daemon.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test.each(MATRIX)(
|
||||
"$name",
|
||||
async (scenario) => {
|
||||
const session = await launchClaudeRewindSession(
|
||||
harness,
|
||||
`claude-rewind-${scenario.mode}-${scenario.turnCount}-${scenario.rewindTurn}`,
|
||||
);
|
||||
const turns = buildTurns(scenario);
|
||||
|
||||
try {
|
||||
for (const turn of turns) {
|
||||
await sendClaudeWriteTurn(harness, session, turn);
|
||||
}
|
||||
|
||||
const beforeTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const targetTurn = turns[scenario.rewindTurn - 1];
|
||||
const targetMessageId = userMessageIdForToken(beforeTimeline, targetTurn.promptToken);
|
||||
const sessionIdBefore = await runtimeSessionId(harness, session);
|
||||
expectSessionId(sessionIdBefore);
|
||||
|
||||
await harness.client.rewindAgent(session.agentId, targetMessageId, scenario.mode);
|
||||
|
||||
const afterTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const sessionIdAfter = await runtimeSessionId(harness, session);
|
||||
const expectedKeptConversation =
|
||||
scenario.mode === "files" ? turns : turns.slice(0, scenario.rewindTurn - 1);
|
||||
const expectedKeptFiles =
|
||||
scenario.mode === "conversation" ? turns : turns.slice(0, scenario.rewindTurn - 1);
|
||||
|
||||
assertTimeline(afterTimeline, expectedKeptConversation);
|
||||
await assertFiles(session, expectedKeptFiles);
|
||||
|
||||
if (scenario.mode === "files") {
|
||||
expect(sessionIdAfter).toBe(sessionIdBefore);
|
||||
} else {
|
||||
expectSessionId(sessionIdAfter);
|
||||
expect(sessionIdAfter).not.toBe(sessionIdBefore);
|
||||
}
|
||||
|
||||
const snapshot = await harness.client.fetchAgent(session.agentId);
|
||||
expect(snapshot?.agent.status).toBe("idle");
|
||||
expect(snapshot?.agent.pendingPermissions).toEqual([]);
|
||||
} finally {
|
||||
await closeClaudeRewindSession(session);
|
||||
}
|
||||
},
|
||||
420_000,
|
||||
);
|
||||
|
||||
test("emits only the deliberate fork session switch after conversation rewind and continue", async () => {
|
||||
const session = await launchClaudeRewindSession(harness, "claude-rewind-no-session-roundtrip");
|
||||
const sessionSwitches: string[] = [];
|
||||
const unsubscribe = harness.client.on((event) => {
|
||||
if (event.type !== "agent_stream" || event.agentId !== session.agentId) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.event.type === "timeline" &&
|
||||
event.event.item.type === "assistant_message" &&
|
||||
event.event.item.text.startsWith("Claude switched to a new session:")
|
||||
) {
|
||||
sessionSwitches.push(event.event.item.text);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await sendClaudeReplyTurn(
|
||||
harness,
|
||||
session,
|
||||
"PASEO_RW_NO_ROUNDTRIP_T1. Reply exactly: PASEO_RW_NO_ROUNDTRIP_T1_DONE",
|
||||
);
|
||||
await sendClaudeReplyTurn(
|
||||
harness,
|
||||
session,
|
||||
"PASEO_RW_NO_ROUNDTRIP_T2. Reply exactly: PASEO_RW_NO_ROUNDTRIP_T2_DONE",
|
||||
);
|
||||
|
||||
const beforeTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const targetMessageId = userMessageIdForToken(beforeTimeline, "PASEO_RW_NO_ROUNDTRIP_T2");
|
||||
const sessionIdBeforeRewind = await runtimeSessionId(harness, session);
|
||||
expectSessionId(sessionIdBeforeRewind);
|
||||
|
||||
await harness.client.rewindAgent(session.agentId, targetMessageId, "conversation");
|
||||
const forkedSessionId = await runtimeSessionId(harness, session);
|
||||
expectSessionId(forkedSessionId);
|
||||
expect(forkedSessionId).not.toBe(sessionIdBeforeRewind);
|
||||
|
||||
await sendClaudeReplyTurn(
|
||||
harness,
|
||||
session,
|
||||
"PASEO_RW_NO_ROUNDTRIP_T3. Reply exactly: PASEO_RW_NO_ROUNDTRIP_T3_DONE",
|
||||
);
|
||||
|
||||
const finalSessionId = await runtimeSessionId(harness, session);
|
||||
expect(finalSessionId).toBe(forkedSessionId);
|
||||
expect(sessionSwitches).toEqual([
|
||||
`Claude switched to a new session: ${sessionIdBeforeRewind} -> ${forkedSessionId}`,
|
||||
]);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await closeClaudeRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig } from "./agent-configs.js";
|
||||
import {
|
||||
closeRewindSession,
|
||||
fetchTimelineItems,
|
||||
fileExists,
|
||||
readScratchFile,
|
||||
tmpRewindCwd,
|
||||
userMessageIdForToken,
|
||||
} from "./test-utils/rewind-helpers.js";
|
||||
|
||||
interface CodexRewindHarness {
|
||||
client: DaemonClient;
|
||||
daemon: TestPaseoDaemon;
|
||||
}
|
||||
|
||||
interface CodexRewindSession {
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
scratchPath: string;
|
||||
}
|
||||
|
||||
const TURN_TIMEOUT_MS = 180_000;
|
||||
|
||||
async function fetchThreadId(client: DaemonClient, agentId: string): Promise<string> {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
const threadId = result?.agent.persistence?.sessionId ?? result?.agent.runtimeInfo?.sessionId;
|
||||
if (!threadId) {
|
||||
throw new Error(`Agent ${agentId} does not have a visible Codex thread id`);
|
||||
}
|
||||
return threadId;
|
||||
}
|
||||
|
||||
function expectThreadId(value: string): void {
|
||||
expect(value).toMatch(/^[a-f0-9-]{36}$/);
|
||||
}
|
||||
|
||||
function roleItems(items: AgentTimelineItem[], role: "user_message" | "assistant_message") {
|
||||
return items.filter((item) => item.type === role);
|
||||
}
|
||||
|
||||
function expectTimeline(
|
||||
items: AgentTimelineItem[],
|
||||
input: { userTexts: string[]; assistantCount: number },
|
||||
): void {
|
||||
const userMessages = roleItems(items, "user_message");
|
||||
const assistantMessages = roleItems(items, "assistant_message");
|
||||
|
||||
expect(userMessages).toHaveLength(input.userTexts.length);
|
||||
expect(userMessages.map((message) => message.text)).toEqual(input.userTexts);
|
||||
expect(assistantMessages).toHaveLength(input.assistantCount);
|
||||
}
|
||||
|
||||
async function createdFilesAtCwd(session: CodexRewindSession): Promise<string[]> {
|
||||
const entries = await readdir(session.cwd);
|
||||
return entries
|
||||
.filter((name) => name === "rewind-scratch.txt" || name === "codex-dummy.txt")
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function expectCreatedFiles(
|
||||
session: CodexRewindSession,
|
||||
expectedNames: string[],
|
||||
): Promise<void> {
|
||||
await expect(createdFilesAtCwd(session)).resolves.toEqual([...expectedNames].sort());
|
||||
}
|
||||
|
||||
async function launchCodexRewindSession(
|
||||
harness: CodexRewindHarness,
|
||||
title: string,
|
||||
): Promise<CodexRewindSession> {
|
||||
const cwd = tmpRewindCwd("daemon-real-codex-rewind-");
|
||||
const scratchPath = path.join(cwd, "rewind-scratch.txt");
|
||||
await writeFile(scratchPath, "BASE\n", "utf8");
|
||||
|
||||
const agent = await harness.client.createAgent({
|
||||
cwd,
|
||||
title,
|
||||
...getFullAccessConfig("codex"),
|
||||
});
|
||||
|
||||
return { agentId: agent.id, cwd, scratchPath };
|
||||
}
|
||||
|
||||
async function closeCodexRewindSession(session: CodexRewindSession): Promise<void> {
|
||||
closeRewindSession(session);
|
||||
}
|
||||
|
||||
function editPrompt(input: {
|
||||
fileName: string;
|
||||
promptToken: string;
|
||||
content: string;
|
||||
doneToken: string;
|
||||
}): string {
|
||||
return [
|
||||
`PASEO_CODEX_REWIND_PROMPT_${input.promptToken}.`,
|
||||
`Use apply_patch to make ${input.fileName} contain exactly:`,
|
||||
"```",
|
||||
input.content.trimEnd(),
|
||||
"```",
|
||||
`When the file is saved, reply exactly: ${input.doneToken}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function singleCreatePrompt(): string {
|
||||
return [
|
||||
"PASEO_CODEX_REWIND_PROMPT_SINGLE_CREATE.",
|
||||
"Use apply_patch to create codex-dummy.txt with exactly CODEX_CREATED.",
|
||||
"When the file is saved, reply exactly: CODEX_SINGLE_CREATE_DONE",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function askCodexToEditFile(
|
||||
harness: CodexRewindHarness,
|
||||
session: CodexRewindSession,
|
||||
input: {
|
||||
promptToken: string;
|
||||
content: string;
|
||||
doneToken: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await harness.client.sendMessage(
|
||||
session.agentId,
|
||||
editPrompt({
|
||||
fileName: path.basename(session.scratchPath),
|
||||
promptToken: input.promptToken,
|
||||
content: input.content,
|
||||
doneToken: input.doneToken,
|
||||
}),
|
||||
);
|
||||
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
}
|
||||
|
||||
describe("daemon E2E (real codex) - rewind", () => {
|
||||
let harness: CodexRewindHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "codex-rewind-real" } });
|
||||
harness = { client, daemon };
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.client.close().catch(() => undefined);
|
||||
await harness?.daemon.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("rewinds a real Codex conversation after a single edit turn while leaving the file edit on disk", async () => {
|
||||
const session = await launchCodexRewindSession(harness, "codex-rewind-single-edit-real");
|
||||
|
||||
try {
|
||||
await askCodexToEditFile(harness, session, {
|
||||
promptToken: "SINGLE",
|
||||
content: "BASE\nCODEX_SINGLE_MARKER\n",
|
||||
doneToken: "CODEX_SINGLE_DONE",
|
||||
});
|
||||
const timeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const messageId = userMessageIdForToken(timeline, "CODEX_REWIND_PROMPT_SINGLE");
|
||||
|
||||
await harness.client.rewindAgent(session.agentId, messageId, "conversation");
|
||||
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const fileText = await readScratchFile(session);
|
||||
|
||||
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
|
||||
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
|
||||
expect(fileText).toBe("BASE\nCODEX_SINGLE_MARKER\n");
|
||||
} finally {
|
||||
await closeCodexRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test("rewinds a real Codex conversation after a single file-creation turn while leaving the created file on disk", async () => {
|
||||
const session = await launchCodexRewindSession(harness, "codex-rewind-single-create-real");
|
||||
const dummyPath = path.join(session.cwd, "codex-dummy.txt");
|
||||
|
||||
try {
|
||||
await harness.client.sendMessage(session.agentId, singleCreatePrompt());
|
||||
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
await expect(fileExists(dummyPath)).resolves.toBe(true);
|
||||
|
||||
const timeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const messageId = userMessageIdForToken(timeline, "CODEX_REWIND_PROMPT_SINGLE_CREATE");
|
||||
|
||||
await harness.client.rewindAgent(session.agentId, messageId, "conversation");
|
||||
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
|
||||
await expect(fileExists(dummyPath)).resolves.toBe(true);
|
||||
await expectCreatedFiles(session, ["codex-dummy.txt", "rewind-scratch.txt"]);
|
||||
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
|
||||
} finally {
|
||||
await closeCodexRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test("rewinds a real Codex conversation to a forked thread while leaving file edits on disk", async () => {
|
||||
const session = await launchCodexRewindSession(harness, "codex-rewind-conversation-real");
|
||||
|
||||
try {
|
||||
await askCodexToEditFile(harness, session, {
|
||||
promptToken: "FIRST",
|
||||
content: "BASE\nCODEX_FIRST_MARKER\n",
|
||||
doneToken: "CODEX_FIRST_DONE",
|
||||
});
|
||||
const firstTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const firstMessageId = userMessageIdForToken(firstTimeline, "CODEX_REWIND_PROMPT_FIRST");
|
||||
|
||||
await askCodexToEditFile(harness, session, {
|
||||
promptToken: "SECOND",
|
||||
content: "BASE\nCODEX_FIRST_MARKER\nCODEX_SECOND_MARKER\n",
|
||||
doneToken: "CODEX_SECOND_DONE",
|
||||
});
|
||||
const oldThreadId = await fetchThreadId(harness.client, session.agentId);
|
||||
expectThreadId(oldThreadId);
|
||||
|
||||
await harness.client.rewindAgent(session.agentId, firstMessageId, "conversation");
|
||||
const newThreadId = await fetchThreadId(harness.client, session.agentId);
|
||||
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const fileText = await readScratchFile(session);
|
||||
|
||||
expect(newThreadId).not.toBe(oldThreadId);
|
||||
expectThreadId(newThreadId);
|
||||
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
|
||||
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
|
||||
expect(fileText).toBe("BASE\nCODEX_FIRST_MARKER\nCODEX_SECOND_MARKER\n");
|
||||
} finally {
|
||||
await closeCodexRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import type { AgentLifecycleStatus } from "../../shared/agent-lifecycle.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig } from "./agent-configs.js";
|
||||
import {
|
||||
closeRewindSession,
|
||||
fetchTimelineItems,
|
||||
fileExists,
|
||||
readScratchFile,
|
||||
tmpRewindCwd,
|
||||
userMessageIdForToken,
|
||||
} from "./test-utils/rewind-helpers.js";
|
||||
|
||||
interface OpenCodeRewindHarness {
|
||||
client: DaemonClient;
|
||||
daemon: TestPaseoDaemon;
|
||||
}
|
||||
|
||||
interface OpenCodeRewindSession {
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
scratchPath: string;
|
||||
}
|
||||
|
||||
interface AgentStatusRecord {
|
||||
at: number;
|
||||
status: AgentLifecycleStatus;
|
||||
}
|
||||
|
||||
const TURN_TIMEOUT_MS = 180_000;
|
||||
|
||||
async function launchOpenCodeRewindSession(
|
||||
harness: OpenCodeRewindHarness,
|
||||
title: string,
|
||||
): Promise<OpenCodeRewindSession> {
|
||||
const cwd = tmpRewindCwd("daemon-real-opencode-rewind-", { realpath: true });
|
||||
const scratchPath = path.join(cwd, "rewind-scratch.txt");
|
||||
execFileSync("git", ["init"], { cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||
cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd, stdio: "ignore" });
|
||||
await writeFile(scratchPath, "BASE\n", "utf8");
|
||||
execFileSync("git", ["add", "rewind-scratch.txt"], { cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "base"], { cwd, stdio: "ignore" });
|
||||
|
||||
const agent = await harness.client.createAgent({
|
||||
cwd,
|
||||
title,
|
||||
...getFullAccessConfig("opencode"),
|
||||
});
|
||||
|
||||
return { agentId: agent.id, cwd, scratchPath };
|
||||
}
|
||||
|
||||
async function runtimeSessionId(
|
||||
harness: OpenCodeRewindHarness,
|
||||
session: OpenCodeRewindSession,
|
||||
): Promise<string> {
|
||||
const snapshot = await harness.client.fetchAgent(session.agentId);
|
||||
const sessionId =
|
||||
snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId;
|
||||
if (!sessionId) {
|
||||
throw new Error(`Agent ${session.agentId} does not have a visible OpenCode session id`);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function expectOpenCodeSessionId(value: string): void {
|
||||
expect(value).toMatch(/^ses_/);
|
||||
}
|
||||
|
||||
function roleItems(items: AgentTimelineItem[], role: "user_message" | "assistant_message") {
|
||||
return items.filter((item) => item.type === role);
|
||||
}
|
||||
|
||||
function expectTimeline(
|
||||
items: AgentTimelineItem[],
|
||||
input: { userTexts: string[]; assistantCount: number },
|
||||
): void {
|
||||
const userMessages = roleItems(items, "user_message");
|
||||
const assistantMessages = roleItems(items, "assistant_message");
|
||||
|
||||
expect(userMessages).toHaveLength(input.userTexts.length);
|
||||
expect(userMessages.map((message) => message.text)).toEqual(input.userTexts);
|
||||
expect(assistantMessages).toHaveLength(input.assistantCount);
|
||||
}
|
||||
|
||||
function expectUserMessageCount(items: AgentTimelineItem[], expected: number): void {
|
||||
expect(roleItems(items, "user_message")).toHaveLength(expected);
|
||||
}
|
||||
|
||||
async function recordAgentStatusTransitions(
|
||||
harness: OpenCodeRewindHarness,
|
||||
session: OpenCodeRewindSession,
|
||||
): Promise<{ records: AgentStatusRecord[]; stop: () => void }> {
|
||||
const records: AgentStatusRecord[] = [];
|
||||
const initial = await harness.client.fetchAgent(session.agentId);
|
||||
if (initial?.agent.status) {
|
||||
records.push({ at: Date.now(), status: initial.agent.status });
|
||||
}
|
||||
const unsubscribe = harness.client.on("agent_update", (message) => {
|
||||
if (message.payload.kind !== "upsert" || message.payload.agent.id !== session.agentId) {
|
||||
return;
|
||||
}
|
||||
records.push({ at: Date.now(), status: message.payload.agent.status });
|
||||
});
|
||||
|
||||
return { records, stop: unsubscribe };
|
||||
}
|
||||
|
||||
function screenStatusForInvariant(status: AgentLifecycleStatus): "loading" | AgentLifecycleStatus {
|
||||
return status === "initializing" ? "loading" : status;
|
||||
}
|
||||
|
||||
function expectNoLoadingRegression(records: AgentStatusRecord[]): void {
|
||||
const statuses = records.map((record) => screenStatusForInvariant(record.status));
|
||||
for (let index = 1; index < statuses.length; index += 1) {
|
||||
expect([statuses[index - 1], statuses[index]]).not.toEqual(["running", "loading"]);
|
||||
}
|
||||
for (let index = 2; index < statuses.length; index += 1) {
|
||||
expect([statuses[index - 2], statuses[index - 1], statuses[index]]).not.toEqual([
|
||||
"idle",
|
||||
"loading",
|
||||
"idle",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectAgentIdle(
|
||||
harness: OpenCodeRewindHarness,
|
||||
session: OpenCodeRewindSession,
|
||||
): Promise<void> {
|
||||
const snapshot = await harness.client.fetchAgent(session.agentId);
|
||||
expect(snapshot?.agent.status).toBe("idle");
|
||||
}
|
||||
|
||||
async function createdFilesAtCwd(session: OpenCodeRewindSession): Promise<string[]> {
|
||||
const entries = await readdir(session.cwd);
|
||||
return entries
|
||||
.filter((name) =>
|
||||
["rewind-scratch.txt", "opencode-multi-a.txt", "opencode-multi-b.txt"].includes(name),
|
||||
)
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function expectCreatedFiles(
|
||||
session: OpenCodeRewindSession,
|
||||
expectedNames: string[],
|
||||
): Promise<void> {
|
||||
await expect(createdFilesAtCwd(session)).resolves.toEqual([...expectedNames].sort());
|
||||
}
|
||||
|
||||
async function closeOpenCodeRewindSession(session: OpenCodeRewindSession): Promise<void> {
|
||||
closeRewindSession(session);
|
||||
}
|
||||
|
||||
function editPrompt(input: {
|
||||
fileName: string;
|
||||
promptToken: string;
|
||||
content: string;
|
||||
doneToken: string;
|
||||
}): string {
|
||||
return [
|
||||
`PASEO_OPENCODE_REWIND_PROMPT_${input.promptToken}.`,
|
||||
`Use the edit or write tool, not shell commands, to make ${input.fileName} contain exactly:`,
|
||||
"```",
|
||||
input.content.trimEnd(),
|
||||
"```",
|
||||
`When the file is saved, reply exactly: ${input.doneToken}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function askOpenCodeToEditFile(
|
||||
harness: OpenCodeRewindHarness,
|
||||
session: OpenCodeRewindSession,
|
||||
input: {
|
||||
promptToken: string;
|
||||
content: string;
|
||||
doneToken: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await harness.client.sendMessage(
|
||||
session.agentId,
|
||||
editPrompt({
|
||||
fileName: path.basename(session.scratchPath),
|
||||
promptToken: input.promptToken,
|
||||
content: input.content,
|
||||
doneToken: input.doneToken,
|
||||
}),
|
||||
);
|
||||
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
}
|
||||
|
||||
async function rewindOpenCode(
|
||||
harness: OpenCodeRewindHarness,
|
||||
session: OpenCodeRewindSession,
|
||||
messageId: string,
|
||||
): Promise<void> {
|
||||
await harness.client.rewindAgent(session.agentId, messageId, "both");
|
||||
}
|
||||
|
||||
describe("daemon E2E (real opencode) - rewind", () => {
|
||||
let harness: OpenCodeRewindHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { opencode: new OpenCodeAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "opencode-rewind-real" } });
|
||||
harness = { client, daemon };
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.client.close().catch(() => undefined);
|
||||
await harness?.daemon.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("rewinds conversation and files after a single real OpenCode edit turn", async () => {
|
||||
const session = await launchOpenCodeRewindSession(harness, "opencode-rewind-single-edit-real");
|
||||
|
||||
try {
|
||||
await askOpenCodeToEditFile(harness, session, {
|
||||
promptToken: "SINGLE",
|
||||
content: "BASE\nOPENCODE_SINGLE_MARKER\n",
|
||||
doneToken: "OPENCODE_SINGLE_DONE",
|
||||
});
|
||||
const timeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
expectUserMessageCount(timeline, 1);
|
||||
const messageId = userMessageIdForToken(timeline, "OPENCODE_REWIND_PROMPT_SINGLE");
|
||||
const sessionIdBefore = await runtimeSessionId(harness, session);
|
||||
expectOpenCodeSessionId(sessionIdBefore);
|
||||
const statusTransitions = await recordAgentStatusTransitions(harness, session);
|
||||
|
||||
try {
|
||||
await rewindOpenCode(harness, session, messageId);
|
||||
} finally {
|
||||
statusTransitions.stop();
|
||||
}
|
||||
const sessionIdAfter = await runtimeSessionId(harness, session);
|
||||
const fileText = await readScratchFile(session);
|
||||
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
|
||||
expect(sessionIdAfter).toBe(sessionIdBefore);
|
||||
await expectAgentIdle(harness, session);
|
||||
expectNoLoadingRegression(statusTransitions.records);
|
||||
expect(fileText).toBe("BASE\n");
|
||||
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
|
||||
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
|
||||
} finally {
|
||||
await closeOpenCodeRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test("records exactly one plain-text user message for a real OpenCode turn", async () => {
|
||||
const session = await launchOpenCodeRewindSession(
|
||||
harness,
|
||||
"opencode-plain-text-user-message-real",
|
||||
);
|
||||
const prompt = "PASEO_OPENCODE_PLAIN_TEXT_DUP_CHECK. Reply exactly: OPENCODE_PLAIN_TEXT_DONE";
|
||||
|
||||
try {
|
||||
await harness.client.sendMessage(session.agentId, prompt);
|
||||
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
|
||||
const timeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const userMessages = roleItems(timeline, "user_message");
|
||||
expect(userMessages).toHaveLength(1);
|
||||
expect(userMessages[0]?.text).toBe(prompt);
|
||||
} finally {
|
||||
await closeOpenCodeRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test("rewinds a real OpenCode read-only turn without changing files", async () => {
|
||||
const session = await launchOpenCodeRewindSession(harness, "opencode-rewind-read-only-real");
|
||||
|
||||
try {
|
||||
await harness.client.sendMessage(
|
||||
session.agentId,
|
||||
[
|
||||
"PASEO_OPENCODE_REWIND_PROMPT_READ_ONLY.",
|
||||
`Inspect ${path.basename(session.scratchPath)} without editing files.`,
|
||||
"Reply exactly: OPENCODE_READ_ONLY_DONE",
|
||||
].join(" "),
|
||||
);
|
||||
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
|
||||
const timeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
expectUserMessageCount(timeline, 1);
|
||||
const messageId = userMessageIdForToken(timeline, "OPENCODE_REWIND_PROMPT_READ_ONLY");
|
||||
const sessionIdBefore = await runtimeSessionId(harness, session);
|
||||
expectOpenCodeSessionId(sessionIdBefore);
|
||||
const statusTransitions = await recordAgentStatusTransitions(harness, session);
|
||||
|
||||
try {
|
||||
await rewindOpenCode(harness, session, messageId);
|
||||
} finally {
|
||||
statusTransitions.stop();
|
||||
}
|
||||
const sessionIdAfter = await runtimeSessionId(harness, session);
|
||||
const fileText = await readScratchFile(session);
|
||||
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
|
||||
expect(sessionIdAfter).toBe(sessionIdBefore);
|
||||
await expectAgentIdle(harness, session);
|
||||
expectNoLoadingRegression(statusTransitions.records);
|
||||
expect(fileText).toBe("BASE\n");
|
||||
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
|
||||
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
|
||||
} finally {
|
||||
await closeOpenCodeRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test("rewinds every file from a single real OpenCode multi-tool edit turn", async () => {
|
||||
const session = await launchOpenCodeRewindSession(harness, "opencode-rewind-multi-edit-real");
|
||||
const firstPath = path.join(session.cwd, "opencode-multi-a.txt");
|
||||
const secondPath = path.join(session.cwd, "opencode-multi-b.txt");
|
||||
|
||||
try {
|
||||
await harness.client.sendMessage(
|
||||
session.agentId,
|
||||
[
|
||||
"PASEO_OPENCODE_REWIND_PROMPT_MULTI_EDIT.",
|
||||
"Create opencode-multi-a.txt with exactly OPENCODE_MULTI_A.",
|
||||
"Create opencode-multi-b.txt with exactly OPENCODE_MULTI_B.",
|
||||
"Do not use shell commands.",
|
||||
"Reply exactly: OPENCODE_MULTI_EDIT_DONE",
|
||||
].join("\n"),
|
||||
);
|
||||
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
await expect(fileExists(firstPath)).resolves.toBe(true);
|
||||
await expect(fileExists(secondPath)).resolves.toBe(true);
|
||||
|
||||
const timeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
expectUserMessageCount(timeline, 1);
|
||||
const messageId = userMessageIdForToken(timeline, "OPENCODE_REWIND_PROMPT_MULTI_EDIT");
|
||||
const sessionIdBefore = await runtimeSessionId(harness, session);
|
||||
expectOpenCodeSessionId(sessionIdBefore);
|
||||
const statusTransitions = await recordAgentStatusTransitions(harness, session);
|
||||
|
||||
try {
|
||||
await rewindOpenCode(harness, session, messageId);
|
||||
} finally {
|
||||
statusTransitions.stop();
|
||||
}
|
||||
const sessionIdAfter = await runtimeSessionId(harness, session);
|
||||
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
|
||||
expect(sessionIdAfter).toBe(sessionIdBefore);
|
||||
await expectAgentIdle(harness, session);
|
||||
expectNoLoadingRegression(statusTransitions.records);
|
||||
await expect(fileExists(firstPath)).resolves.toBe(false);
|
||||
await expect(fileExists(secondPath)).resolves.toBe(false);
|
||||
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
|
||||
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
|
||||
} finally {
|
||||
await closeOpenCodeRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test("rewinds conversation and files together against a real OpenCode session", async () => {
|
||||
const session = await launchOpenCodeRewindSession(harness, "opencode-rewind-both-real");
|
||||
|
||||
try {
|
||||
await askOpenCodeToEditFile(harness, session, {
|
||||
promptToken: "BOTH_FIRST",
|
||||
content: "BASE\nOPENCODE_BOTH_FIRST_MARKER\n",
|
||||
doneToken: "OPENCODE_BOTH_FIRST_DONE",
|
||||
});
|
||||
const fileTextAfterFirstTurn = await readScratchFile(session);
|
||||
const firstTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
expectUserMessageCount(firstTimeline, 1);
|
||||
|
||||
await askOpenCodeToEditFile(harness, session, {
|
||||
promptToken: "BOTH_SECOND",
|
||||
content: "BASE\nOPENCODE_BOTH_FIRST_MARKER\nOPENCODE_BOTH_SECOND_MARKER\n",
|
||||
doneToken: "OPENCODE_BOTH_SECOND_DONE",
|
||||
});
|
||||
const secondTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
expectUserMessageCount(secondTimeline, 2);
|
||||
const secondMessageId = userMessageIdForToken(secondTimeline, "BOTH_SECOND");
|
||||
const sessionIdBefore = await runtimeSessionId(harness, session);
|
||||
expectOpenCodeSessionId(sessionIdBefore);
|
||||
const statusTransitions = await recordAgentStatusTransitions(harness, session);
|
||||
|
||||
try {
|
||||
await rewindOpenCode(harness, session, secondMessageId);
|
||||
} finally {
|
||||
statusTransitions.stop();
|
||||
}
|
||||
const sessionIdAfter = await runtimeSessionId(harness, session);
|
||||
const fileText = await readScratchFile(session);
|
||||
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
|
||||
expect(sessionIdAfter).toBe(sessionIdBefore);
|
||||
await expectAgentIdle(harness, session);
|
||||
expectNoLoadingRegression(statusTransitions.records);
|
||||
expect(fileText).toBe(fileTextAfterFirstTurn);
|
||||
await expectCreatedFiles(session, ["rewind-scratch.txt"]);
|
||||
expectTimeline(rewoundTimeline, {
|
||||
userTexts: [
|
||||
editPrompt({
|
||||
fileName: path.basename(session.scratchPath),
|
||||
promptToken: "BOTH_FIRST",
|
||||
content: "BASE\nOPENCODE_BOTH_FIRST_MARKER\n",
|
||||
doneToken: "OPENCODE_BOTH_FIRST_DONE",
|
||||
}),
|
||||
],
|
||||
assistantCount: 1,
|
||||
});
|
||||
} finally {
|
||||
await closeOpenCodeRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { readdir } from "node:fs/promises";
|
||||
import pino from "pino";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
|
||||
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import {
|
||||
closeRewindSession,
|
||||
fetchTimelineItems,
|
||||
tmpRewindCwd,
|
||||
userMessageIdForToken,
|
||||
} from "./test-utils/rewind-helpers.js";
|
||||
|
||||
interface PiRewindHarness {
|
||||
client: DaemonClient;
|
||||
daemon: TestPaseoDaemon;
|
||||
}
|
||||
|
||||
interface PiRewindSession {
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
const TURN_TIMEOUT_MS = 240_000;
|
||||
const PI_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
|
||||
|
||||
async function launchPiRewindSession(
|
||||
harness: PiRewindHarness,
|
||||
title: string,
|
||||
): Promise<PiRewindSession> {
|
||||
const cwd = tmpRewindCwd("daemon-real-pi-rewind-");
|
||||
const agent = await harness.client.createAgent({
|
||||
cwd,
|
||||
title,
|
||||
provider: "pi",
|
||||
model: PI_REAL_TEST_MODEL,
|
||||
thinkingOptionId: "medium",
|
||||
});
|
||||
|
||||
return { agentId: agent.id, cwd };
|
||||
}
|
||||
|
||||
async function closePiRewindSession(session: PiRewindSession): Promise<void> {
|
||||
closeRewindSession(session);
|
||||
}
|
||||
|
||||
async function runtimeSessionId(
|
||||
harness: PiRewindHarness,
|
||||
session: PiRewindSession,
|
||||
): Promise<string> {
|
||||
const snapshot = await harness.client.fetchAgent(session.agentId);
|
||||
const sessionId =
|
||||
snapshot?.agent.runtimeInfo?.sessionId ?? snapshot?.agent.persistence?.sessionId;
|
||||
if (!sessionId) {
|
||||
throw new Error(`Agent ${session.agentId} does not have a visible Pi session id`);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function expectPiSessionId(value: string): void {
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
function piPrompt(input: { promptToken: string; doneToken: string }): string {
|
||||
return [
|
||||
`PASEO_PI_REWIND_PROMPT_${input.promptToken}.`,
|
||||
"Remember this marker for the conversation.",
|
||||
`Reply exactly: ${input.doneToken}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function roleItems(items: AgentTimelineItem[], role: "user_message" | "assistant_message") {
|
||||
return items.filter((item) => item.type === role);
|
||||
}
|
||||
|
||||
function expectTimeline(
|
||||
items: AgentTimelineItem[],
|
||||
input: { userTexts: string[]; assistantCount: number },
|
||||
): void {
|
||||
const userMessages = roleItems(items, "user_message");
|
||||
const assistantMessages = roleItems(items, "assistant_message");
|
||||
|
||||
expect(userMessages).toHaveLength(input.userTexts.length);
|
||||
expect(userMessages.map((message) => message.text)).toEqual(input.userTexts);
|
||||
expect(assistantMessages).toHaveLength(input.assistantCount);
|
||||
}
|
||||
|
||||
async function expectNoCreatedFiles(session: PiRewindSession): Promise<void> {
|
||||
await expect(readdir(session.cwd)).resolves.toEqual([]);
|
||||
}
|
||||
|
||||
async function askPi(
|
||||
harness: PiRewindHarness,
|
||||
session: PiRewindSession,
|
||||
input: { promptToken: string; doneToken: string },
|
||||
): Promise<void> {
|
||||
await harness.client.sendMessage(session.agentId, piPrompt(input));
|
||||
const finish = await harness.client.waitForFinish(session.agentId, TURN_TIMEOUT_MS);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
}
|
||||
|
||||
describe("daemon E2E (real pi) - rewind", () => {
|
||||
let harness: PiRewindHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { pi: new PiRpcAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "pi-rewind-real" } });
|
||||
harness = { client, daemon };
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.client.close().catch(() => undefined);
|
||||
await harness?.daemon.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("rewinds a real Pi conversation after a single turn and keeps the session usable", async () => {
|
||||
const session = await launchPiRewindSession(harness, "pi-rewind-single-conversation-real");
|
||||
|
||||
try {
|
||||
await askPi(harness, session, {
|
||||
promptToken: "SINGLE",
|
||||
doneToken: "PI_SINGLE_DONE",
|
||||
});
|
||||
const firstTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const firstMessageId = userMessageIdForToken(firstTimeline, "PI_REWIND_PROMPT_SINGLE");
|
||||
const sessionIdBefore = await runtimeSessionId(harness, session);
|
||||
expectPiSessionId(sessionIdBefore);
|
||||
|
||||
await harness.client.rewindAgent(session.agentId, firstMessageId, "conversation");
|
||||
const sessionIdAfter = await runtimeSessionId(harness, session);
|
||||
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
|
||||
expect(sessionIdAfter).toBe(sessionIdBefore);
|
||||
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
|
||||
await expectNoCreatedFiles(session);
|
||||
|
||||
await askPi(harness, session, {
|
||||
promptToken: "AFTER_SINGLE",
|
||||
doneToken: "PI_AFTER_SINGLE_DONE",
|
||||
});
|
||||
const nextTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
|
||||
expectTimeline(nextTimeline, {
|
||||
userTexts: [piPrompt({ promptToken: "AFTER_SINGLE", doneToken: "PI_AFTER_SINGLE_DONE" })],
|
||||
assistantCount: 1,
|
||||
});
|
||||
await expectNoCreatedFiles(session);
|
||||
} finally {
|
||||
await closePiRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
|
||||
test("rewinds a real Pi conversation to an earlier user message", async () => {
|
||||
const session = await launchPiRewindSession(harness, "pi-rewind-conversation-real");
|
||||
|
||||
try {
|
||||
await askPi(harness, session, {
|
||||
promptToken: "FIRST",
|
||||
doneToken: "PI_FIRST_DONE",
|
||||
});
|
||||
const firstTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
const firstMessageId = userMessageIdForToken(firstTimeline, "PI_REWIND_PROMPT_FIRST");
|
||||
|
||||
await askPi(harness, session, {
|
||||
promptToken: "SECOND",
|
||||
doneToken: "PI_SECOND_DONE",
|
||||
});
|
||||
const sessionIdBefore = await runtimeSessionId(harness, session);
|
||||
expectPiSessionId(sessionIdBefore);
|
||||
|
||||
await harness.client.rewindAgent(session.agentId, firstMessageId, "conversation");
|
||||
const sessionIdAfter = await runtimeSessionId(harness, session);
|
||||
const rewoundTimeline = await fetchTimelineItems(harness.client, session.agentId);
|
||||
|
||||
expect(sessionIdAfter).toBe(sessionIdBefore);
|
||||
expectTimeline(rewoundTimeline, { userTexts: [], assistantCount: 0 });
|
||||
await expectNoCreatedFiles(session);
|
||||
} finally {
|
||||
await closePiRewindSession(session);
|
||||
}
|
||||
}, 420_000);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AgentTimelineItem } from "../../agent/agent-sdk-types.js";
|
||||
import type { DaemonClient } from "../../test-utils/daemon-client.js";
|
||||
|
||||
export interface RewindSessionBase {
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
scratchPath?: string;
|
||||
}
|
||||
|
||||
export function tmpRewindCwd(prefix: string, options?: { realpath?: boolean }): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), prefix));
|
||||
return options?.realpath ? realpathSync(dir) : dir;
|
||||
}
|
||||
|
||||
export function closeRewindSession(session: RewindSessionBase): void {
|
||||
rmSync(session.cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function timelineItems(
|
||||
timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>,
|
||||
): AgentTimelineItem[] {
|
||||
return timeline.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
export function textByRole(
|
||||
items: AgentTimelineItem[],
|
||||
role: "user_message" | "assistant_message",
|
||||
): string {
|
||||
return items
|
||||
.filter((item) => item.type === role)
|
||||
.map((item) => item.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function userMessageIdForToken(items: AgentTimelineItem[], token: string): string {
|
||||
const item = items.find(
|
||||
(candidate) => candidate.type === "user_message" && candidate.text.includes(token),
|
||||
);
|
||||
if (!item?.messageId) {
|
||||
throw new Error(`Timeline did not contain a user message id for ${token}`);
|
||||
}
|
||||
return item.messageId;
|
||||
}
|
||||
|
||||
export async function fetchTimelineItems(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const timeline = await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
return timelineItems(timeline);
|
||||
}
|
||||
|
||||
export async function readScratchFile(session: { scratchPath: string }): Promise<string> {
|
||||
return await readFile(session.scratchPath, "utf8");
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForTimelineItems(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
predicate: (items: AgentTimelineItem[]) => boolean,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const existingItems = await fetchTimelineItems(client, agentId);
|
||||
if (predicate(existingItems)) {
|
||||
return existingItems;
|
||||
}
|
||||
|
||||
return await new Promise<AgentTimelineItem[]>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timed out waiting for timeline items for ${agentId}`));
|
||||
}, 60_000);
|
||||
const unsubscribe = client.on("agent_update", (message) => {
|
||||
if (message.payload.kind !== "upsert" || message.payload.agent.id !== agentId) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const items = await fetchTimelineItems(client, agentId);
|
||||
if (!predicate(items)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(items);
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
reject(error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentProvider,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "../agent/agent-sdk-types.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||
import { fetchTimelineItems } from "./test-utils/rewind-helpers.js";
|
||||
|
||||
type ContractProvider = Extract<AgentProvider, "claude" | "codex" | "opencode" | "pi">;
|
||||
|
||||
interface ProviderContractCase {
|
||||
provider: ContractProvider;
|
||||
title: string;
|
||||
timeoutMs: number;
|
||||
createClient: (logger: pino.Logger) => AgentClient;
|
||||
createConfig: () => AgentSessionConfig;
|
||||
}
|
||||
|
||||
const PI_REAL_TEST_MODEL = "openrouter/google/gemini-2.5-flash-lite";
|
||||
|
||||
const CONTRACT_CASES: ProviderContractCase[] = [
|
||||
{
|
||||
provider: "claude",
|
||||
title: "Claude",
|
||||
timeoutMs: 180_000,
|
||||
createClient: (logger) => new ClaudeAgentClient({ logger }),
|
||||
createConfig: () => getFullAccessConfig("claude"),
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
title: "Codex",
|
||||
timeoutMs: 180_000,
|
||||
createClient: (logger) => new CodexAppServerAgentClient(logger),
|
||||
createConfig: () => getFullAccessConfig("codex"),
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
title: "OpenCode",
|
||||
timeoutMs: 180_000,
|
||||
createClient: (logger) => new OpenCodeAgentClient(logger),
|
||||
createConfig: () => getFullAccessConfig("opencode"),
|
||||
},
|
||||
{
|
||||
provider: "pi",
|
||||
title: "Pi",
|
||||
timeoutMs: 240_000,
|
||||
createClient: (logger) => new PiRpcAgentClient({ logger }),
|
||||
createConfig: () => ({
|
||||
provider: "pi",
|
||||
model: PI_REAL_TEST_MODEL,
|
||||
thinkingOptionId: "medium",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
function tmpCwd(provider: ContractProvider): string {
|
||||
return mkdtempSync(path.join(tmpdir(), `daemon-real-${provider}-user-message-contract-`));
|
||||
}
|
||||
|
||||
function collectUserMessageEvents(client: DaemonClient, agentId: string): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
client.on("agent_stream", (message) => {
|
||||
if (message.type !== "agent_stream" || message.payload.agentId !== agentId) {
|
||||
return;
|
||||
}
|
||||
if (message.payload.event.type !== "timeline") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.event.item.type !== "user_message") {
|
||||
return;
|
||||
}
|
||||
events.push(message.payload.event);
|
||||
});
|
||||
return events;
|
||||
}
|
||||
|
||||
describe.each(CONTRACT_CASES)("daemon E2E (real $provider) - user_message contract", (entry) => {
|
||||
let canRun = false;
|
||||
let daemon: TestPaseoDaemon | null = null;
|
||||
let client: DaemonClient | null = null;
|
||||
let cwd: string | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await isProviderAvailable(entry.provider);
|
||||
});
|
||||
|
||||
beforeEach(async (context) => {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
cwd = tmpCwd(entry.provider);
|
||||
const logger = pino({ level: "silent" });
|
||||
daemon = await createTestPaseoDaemon({
|
||||
agentClients: { [entry.provider]: entry.createClient(logger) },
|
||||
logger,
|
||||
});
|
||||
client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.80",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: `${entry.provider}-user-contract` } });
|
||||
}, 30_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
await daemon?.close().catch(() => undefined);
|
||||
if (cwd) {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
client = null;
|
||||
daemon = null;
|
||||
cwd = null;
|
||||
});
|
||||
|
||||
test(
|
||||
"submit one prompt emits exactly one provider user_message",
|
||||
async () => {
|
||||
if (!client || !cwd) {
|
||||
throw new Error(`${entry.title} user-message contract test was not initialized`);
|
||||
}
|
||||
|
||||
const prompt = `PASEO_USER_MESSAGE_CONTRACT_${entry.provider.toUpperCase()}. Reply exactly: OK.`;
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: `${entry.provider}-user-message-contract`,
|
||||
...entry.createConfig(),
|
||||
});
|
||||
const liveUserEvents = collectUserMessageEvents(client, agent.id);
|
||||
|
||||
await client.sendMessage(agent.id, prompt);
|
||||
const finish = await client.waitForFinish(agent.id, entry.timeoutMs);
|
||||
expect(finish.status).toBe("idle");
|
||||
expect(finish.final?.lastError).toBeUndefined();
|
||||
|
||||
const liveUserMessages = liveUserEvents.filter(
|
||||
(event) => event.type === "timeline" && event.item.type === "user_message",
|
||||
);
|
||||
expect(liveUserMessages).toHaveLength(1);
|
||||
const [liveUserMessage] = liveUserMessages;
|
||||
expect(liveUserMessage?.type).toBe("timeline");
|
||||
expect(liveUserMessage?.item.type).toBe("user_message");
|
||||
expect(liveUserMessage?.item.text).toBe(prompt);
|
||||
expect(liveUserMessage?.item.messageId).toEqual(expect.any(String));
|
||||
expect(liveUserMessage?.item.messageId).not.toBe("");
|
||||
|
||||
const timeline = await fetchTimelineItems(client, agent.id);
|
||||
const canonicalUserMessages = timeline.filter((item) => item.type === "user_message");
|
||||
expect(canonicalUserMessages).toHaveLength(1);
|
||||
expect(canonicalUserMessages[0]?.text).toBe(prompt);
|
||||
expect(canonicalUserMessages[0]?.messageId).toBe(liveUserMessage?.item.messageId);
|
||||
},
|
||||
entry.timeoutMs + 60_000,
|
||||
);
|
||||
});
|
||||
@@ -1775,6 +1775,7 @@ export class Session {
|
||||
private async dispatchInboundMessage(msg: SessionInboundMessage): Promise<void> {
|
||||
const promise =
|
||||
this.dispatchVoiceAndControlMessage(msg) ??
|
||||
this.dispatchAgentRewindMessage(msg) ??
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
this.dispatchAgentConfigMessage(msg) ??
|
||||
this.dispatchCheckoutMessage(msg) ??
|
||||
@@ -1836,6 +1837,15 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchAgentRewindMessage(msg: SessionInboundMessage): Promise<void> | undefined {
|
||||
switch (msg.type) {
|
||||
case "agent.rewind.request":
|
||||
return this.handleAgentRewindRequest(msg);
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDictationStreamStart(
|
||||
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
|
||||
): Promise<void> {
|
||||
@@ -3522,6 +3532,33 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAgentRewindRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "agent.rewind.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.agentManager.rewind(msg.agentId, msg.messageId, msg.mode);
|
||||
this.emit({
|
||||
type: "agent.rewind.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId: msg.agentId,
|
||||
ok: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "agent.rewind.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
agentId: msg.agentId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : "Failed to rewind agent",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async buildAgentSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
gitOptions?: GitSetupOptions,
|
||||
@@ -8794,7 +8831,7 @@ export class Session {
|
||||
agentId,
|
||||
prompt: formatSystemNotificationPrompt(text),
|
||||
unarchive: false,
|
||||
recordUserMessage: false,
|
||||
userMessageVisible: false,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -31,6 +31,9 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
};
|
||||
|
||||
const TEST_FEATURE_ID = "test_feature";
|
||||
|
||||
@@ -1083,6 +1083,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
daemonStatusRpc: true,
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": true,
|
||||
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
||||
rewind: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,4 +143,37 @@ describe("agent feature schemas", () => {
|
||||
expect(parsed.features).toHaveLength(1);
|
||||
expect(parsed.features?.[0]?.id).toBe("fast_mode");
|
||||
});
|
||||
|
||||
it("defaults missing rewind capabilities to false", () => {
|
||||
const parsed = AgentSnapshotPayloadSchema.parse({
|
||||
id: "agent-123",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
model: "gpt-5",
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-04-03T12:00:00.000Z",
|
||||
updatedAt: "2026-04-03T12:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
});
|
||||
|
||||
expect(parsed.capabilities.supportsRewindConversation).toBe(false);
|
||||
expect(parsed.capabilities.supportsRewindFiles).toBe(false);
|
||||
expect(parsed.capabilities.supportsRewindBoth).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,6 +232,12 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
||||
supportsMcpServers: z.boolean(),
|
||||
supportsReasoningStream: z.boolean(),
|
||||
supportsToolInvocations: z.boolean(),
|
||||
// COMPAT(rewind): added in v0.1.X, drop when floor >= v0.1.X.
|
||||
supportsRewindConversation: z.boolean().optional().default(false),
|
||||
// COMPAT(rewind): added in v0.1.X, drop when floor >= v0.1.X.
|
||||
supportsRewindFiles: z.boolean().optional().default(false),
|
||||
// COMPAT(rewind): added in v0.1.X, drop when floor >= v0.1.X.
|
||||
supportsRewindBoth: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
||||
@@ -1246,6 +1252,26 @@ export const SetAgentFeatureResponseMessageSchema = z.object({
|
||||
payload: AgentActionResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const AgentRewindModeSchema = z.enum(["conversation", "files", "both"]);
|
||||
|
||||
export const AgentRewindRequestMessageSchema = z.object({
|
||||
type: z.literal("agent.rewind.request"),
|
||||
agentId: z.string(),
|
||||
messageId: z.string(),
|
||||
mode: AgentRewindModeSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AgentRewindResponseMessageSchema = z.object({
|
||||
type: z.literal("agent.rewind.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
ok: z.boolean(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const UpdateAgentResponseMessageSchema = z.object({
|
||||
type: z.literal("update_agent_response"),
|
||||
payload: AgentActionResponsePayloadSchema,
|
||||
@@ -1854,6 +1880,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModelRequestMessageSchema,
|
||||
SetAgentThinkingRequestMessageSchema,
|
||||
SetAgentFeatureRequestMessageSchema,
|
||||
AgentRewindRequestMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
CheckoutStatusRequestSchema,
|
||||
SubscribeCheckoutDiffRequestSchema,
|
||||
@@ -2095,6 +2122,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
daemonStatusRpc: z.boolean().optional(),
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
"terminal-restore-modes": z.boolean().optional(),
|
||||
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
||||
rewind: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
@@ -3624,6 +3653,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModelResponseMessageSchema,
|
||||
SetAgentThinkingResponseMessageSchema,
|
||||
SetAgentFeatureResponseMessageSchema,
|
||||
AgentRewindResponseMessageSchema,
|
||||
UpdateAgentResponseMessageSchema,
|
||||
ProjectRenameResponseSchema,
|
||||
WaitForFinishResponseMessageSchema,
|
||||
@@ -3763,6 +3793,7 @@ export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMes
|
||||
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
|
||||
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
|
||||
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
|
||||
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
|
||||
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
||||
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
|
||||
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import pino from "pino";
|
||||
import { z } from "zod";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { CLIENT_CAPS } from "./client-capabilities.js";
|
||||
import {
|
||||
AgentSnapshotPayloadSchema,
|
||||
AgentTimelineItemPayloadSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
SessionInboundMessageSchema,
|
||||
@@ -58,8 +59,20 @@ const LegacySubAgentToolCallSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const LegacyAgentCapabilityFlagsSchema = z.object({
|
||||
supportsStreaming: z.boolean(),
|
||||
supportsSessionPersistence: z.boolean(),
|
||||
supportsDynamicModes: z.boolean(),
|
||||
supportsMcpServers: z.boolean(),
|
||||
supportsReasoningStream: z.boolean(),
|
||||
supportsToolInvocations: z.boolean(),
|
||||
});
|
||||
|
||||
const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
|
||||
capabilities: LegacyAgentCapabilityFlagsSchema,
|
||||
});
|
||||
|
||||
interface SessionInternals {
|
||||
buildAgentPayload: (snapshot: unknown) => Promise<null>;
|
||||
handleFetchAgentTimelineRequest: (
|
||||
message: Extract<
|
||||
z.infer<typeof SessionInboundMessageSchema>,
|
||||
@@ -68,12 +81,140 @@ interface SessionInternals {
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
class InMemoryAgentManager {
|
||||
constructor(private readonly rows: AgentTimelineRow[]) {}
|
||||
|
||||
getAgent() {
|
||||
return {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: new Date("2026-05-02T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-05-02T00:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
lifecycle: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: false,
|
||||
supportsRewindFiles: false,
|
||||
supportsRewindBoth: false,
|
||||
},
|
||||
config: { provider: "codex", cwd: "/tmp/project" },
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
persistence: null,
|
||||
historyPrimed: true,
|
||||
lastUsage: undefined,
|
||||
lastError: undefined,
|
||||
attention: { requiresAttention: false, attentionReason: null, attentionTimestamp: null },
|
||||
foregroundTurnWaiters: new Set(),
|
||||
finalizedForegroundTurnIds: new Set(),
|
||||
unsubscribeSession: null,
|
||||
session: null,
|
||||
activeForegroundTurnId: null,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
|
||||
fetchTimeline() {
|
||||
return {
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 3, nextSeq: 4 },
|
||||
rows: this.rows,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
};
|
||||
}
|
||||
|
||||
listAgents() {
|
||||
return [];
|
||||
}
|
||||
|
||||
subscribe() {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyAgentStorage {
|
||||
async list() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async get() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyProjectRegistry {
|
||||
async list() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async get() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async upsert() {}
|
||||
async archive() {}
|
||||
async remove() {}
|
||||
async initialize() {}
|
||||
async existsOnDisk() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyWorkspaceRegistry {
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
|
||||
list() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyDaemonConfigStore {
|
||||
get() {
|
||||
return {
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
};
|
||||
}
|
||||
|
||||
onChange() {
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryWorktreeWorkflow {
|
||||
readonly capturedInputs: unknown[] = [];
|
||||
|
||||
async create(input: unknown) {
|
||||
this.capturedInputs.push(input);
|
||||
return {} as never;
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionForWireCompatTest(options?: {
|
||||
clientCapabilities?: Record<string, unknown> | null;
|
||||
messages?: SessionOutboundMessage[];
|
||||
}): Session {
|
||||
const messages = options?.messages ?? [];
|
||||
const snapshot = { id: "agent-1", provider: "codex" };
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
seq: 1,
|
||||
@@ -100,74 +241,62 @@ function createSessionForWireCompatTest(options?: {
|
||||
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
|
||||
pushTokenStore: {} as SessionOptions["pushTokenStore"],
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
agentManager: {
|
||||
getAgent: vi.fn(() => snapshot),
|
||||
fetchTimeline: vi.fn(() => ({
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 3, nextSeq: 4 },
|
||||
rows,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
})),
|
||||
listAgents: vi.fn(() => []),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
} as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
existsOnDisk: vi.fn(),
|
||||
} as unknown as SessionOptions["projectRegistry"],
|
||||
workspaceRegistry: {
|
||||
get: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as SessionOptions["workspaceRegistry"],
|
||||
agentManager: new InMemoryAgentManager(rows) as unknown as SessionOptions["agentManager"],
|
||||
agentStorage: new EmptyAgentStorage() as unknown as SessionOptions["agentStorage"],
|
||||
projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"],
|
||||
workspaceRegistry:
|
||||
new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"],
|
||||
chatService: {} as SessionOptions["chatService"],
|
||||
scheduleService: {} as SessionOptions["scheduleService"],
|
||||
loopService: {} as SessionOptions["loopService"],
|
||||
checkoutDiffManager: {
|
||||
scheduleRefreshForCwd: vi.fn(),
|
||||
scheduleRefreshForCwd() {},
|
||||
} as unknown as SessionOptions["checkoutDiffManager"],
|
||||
github: {
|
||||
invalidate: vi.fn(),
|
||||
searchIssuesAndPrs: vi.fn(),
|
||||
createPullRequest: vi.fn(),
|
||||
invalidate() {},
|
||||
async searchIssuesAndPrs() {
|
||||
return [];
|
||||
},
|
||||
async createPullRequest() {
|
||||
return null;
|
||||
},
|
||||
} as unknown as SessionOptions["github"],
|
||||
workspaceGitService: {
|
||||
getCheckoutDiff: vi.fn(),
|
||||
getSnapshot: vi.fn(),
|
||||
suggestBranchesForCwd: vi.fn(),
|
||||
listStashes: vi.fn(),
|
||||
peekSnapshot: vi.fn(),
|
||||
validateBranchRef: vi.fn(),
|
||||
hasLocalBranch: vi.fn(),
|
||||
resolveRepoRemoteUrl: vi.fn(),
|
||||
getWorkspaceGitMetadata: vi.fn(),
|
||||
async getCheckoutDiff() {
|
||||
return null;
|
||||
},
|
||||
async getSnapshot() {
|
||||
return null;
|
||||
},
|
||||
async suggestBranchesForCwd() {
|
||||
return [];
|
||||
},
|
||||
async listStashes() {
|
||||
return [];
|
||||
},
|
||||
peekSnapshot() {
|
||||
return null;
|
||||
},
|
||||
async validateBranchRef() {
|
||||
return { ok: false, error: "not found" };
|
||||
},
|
||||
async hasLocalBranch() {
|
||||
return false;
|
||||
},
|
||||
async resolveRepoRemoteUrl() {
|
||||
return null;
|
||||
},
|
||||
async getWorkspaceGitMetadata() {
|
||||
return null;
|
||||
},
|
||||
} as unknown as SessionOptions["workspaceGitService"],
|
||||
daemonConfigStore: {
|
||||
get: vi.fn(() => ({
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
})),
|
||||
onChange: vi.fn(() => () => {}),
|
||||
} as unknown as SessionOptions["daemonConfigStore"],
|
||||
daemonConfigStore:
|
||||
new EmptyDaemonConfigStore() as unknown as SessionOptions["daemonConfigStore"],
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
});
|
||||
|
||||
const internals = session as unknown as SessionInternals;
|
||||
internals.buildAgentPayload = vi.fn(async () => null);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -256,8 +385,82 @@ describe("wire compatibility", () => {
|
||||
expect(parsed.detail.actions).toEqual([]);
|
||||
});
|
||||
|
||||
test("old clients parse agent snapshots with rewind capabilities", () => {
|
||||
const parsed = LegacyAgentSnapshotPayloadSchema.parse({
|
||||
id: "agent-1",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-05-23T00:00:00.000Z",
|
||||
updatedAt: "2026-05-23T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsRewindConversation: true,
|
||||
supportsRewindFiles: true,
|
||||
supportsRewindBoth: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
});
|
||||
|
||||
expect(parsed.capabilities).toEqual({
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("new clients parse agent snapshots without rewind capabilities", () => {
|
||||
const parsed = AgentSnapshotPayloadSchema.parse({
|
||||
id: "agent-1",
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
createdAt: "2026-05-23T00:00:00.000Z",
|
||||
updatedAt: "2026-05-23T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
});
|
||||
|
||||
expect(parsed.capabilities.supportsRewindConversation).toBe(false);
|
||||
expect(parsed.capabilities.supportsRewindFiles).toBe(false);
|
||||
expect(parsed.capabilities.supportsRewindBoth).toBe(false);
|
||||
});
|
||||
|
||||
test("legacy worktree request shape normalizes to the same internal input as the new shape", async () => {
|
||||
const captured: unknown[] = [];
|
||||
const workflow = new InMemoryWorktreeWorkflow();
|
||||
|
||||
const dependencies = {
|
||||
paseoHome: "/tmp/paseo-home",
|
||||
@@ -275,12 +478,9 @@ describe("wire compatibility", () => {
|
||||
activityAt: null,
|
||||
scripts: [],
|
||||
}) as never,
|
||||
emit: vi.fn(),
|
||||
emit() {},
|
||||
sessionLogger: pino({ level: "silent" }),
|
||||
createPaseoWorktreeWorkflow: async (input: unknown) => {
|
||||
captured.push(input);
|
||||
return {} as never;
|
||||
},
|
||||
createPaseoWorktreeWorkflow: workflow.create.bind(workflow),
|
||||
};
|
||||
|
||||
const legacyRequest = SessionInboundMessageSchema.parse({
|
||||
@@ -329,9 +529,9 @@ describe("wire compatibility", () => {
|
||||
await handleCreatePaseoWorktreeRequest(dependencies, legacyRequest);
|
||||
await handleCreatePaseoWorktreeRequest(dependencies, newRequest);
|
||||
|
||||
expect(captured).toHaveLength(2);
|
||||
expect(captured[0]).toEqual(captured[1]);
|
||||
expect(captured[0]).toEqual({
|
||||
expect(workflow.capturedInputs).toHaveLength(2);
|
||||
expect(workflow.capturedInputs[0]).toEqual(workflow.capturedInputs[1]);
|
||||
expect(workflow.capturedInputs[0]).toEqual({
|
||||
cwd: "/tmp/repo",
|
||||
worktreeSlug: "legacy-worktree",
|
||||
firstAgentContext: {
|
||||
|
||||
Reference in New Issue
Block a user