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 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.
|
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` |
|
| `*.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` |
|
| `*.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
|
### 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 relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||||
const maxRelayStartupAttempts = 5;
|
const maxRelayStartupAttempts = 5;
|
||||||
let lastRelayStartupError: unknown = null;
|
let lastRelayStartupError: unknown = null;
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||||
const relayPort = await getAvailablePort();
|
const relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||||
const buffer = createLineBuffer();
|
const buffer = createLineBuffer();
|
||||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||||
|
|
||||||
@@ -729,7 +738,7 @@ export default async function globalSetup() {
|
|||||||
const dictation = await resolveDictationConfig();
|
const dictation = await resolveDictationConfig();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const relayPort = await startRelay();
|
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||||
daemonProcess = startDaemon({
|
daemonProcess = startDaemon({
|
||||||
port,
|
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 path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ export interface TerminalPerfDaemonClient {
|
|||||||
timeout?: number,
|
timeout?: number,
|
||||||
): Promise<{ status: string }>;
|
): Promise<{ status: string }>;
|
||||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||||
|
waitForFinish(
|
||||||
|
agentId: string,
|
||||||
|
timeout?: number,
|
||||||
|
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||||
subscribeTerminal(
|
subscribeTerminal(
|
||||||
terminalId: string,
|
terminalId: string,
|
||||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||||
@@ -68,6 +73,7 @@ interface TerminalPerfDaemonClientConfig {
|
|||||||
url: string;
|
url: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientType: "cli";
|
clientType: "cli";
|
||||||
|
appVersion?: string;
|
||||||
webSocketFactory?: NodeWebSocketFactory;
|
webSocketFactory?: NodeWebSocketFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,12 +97,22 @@ export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient>
|
|||||||
url: getDaemonWsUrl(),
|
url: getDaemonWsUrl(),
|
||||||
clientId: `terminal-perf-${randomUUID()}`,
|
clientId: `terminal-perf-${randomUUID()}`,
|
||||||
clientType: "cli",
|
clientType: "cli",
|
||||||
|
appVersion: loadAppVersion(),
|
||||||
webSocketFactory,
|
webSocketFactory,
|
||||||
});
|
});
|
||||||
await client.connect();
|
await client.connect();
|
||||||
return client;
|
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 {
|
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||||
const serverId = getServerId();
|
const serverId = getServerId();
|
||||||
const route = buildHostWorkspaceRoute(serverId, workspaceId);
|
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",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:browser": "vitest run --project browser",
|
"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",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"build": "npm run build:web",
|
"build": "npm run build:web",
|
||||||
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
"build:web": "npm run build:workspace-deps && expo export --platform web",
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export default defineConfig({
|
|||||||
projects: [
|
projects: [
|
||||||
{
|
{
|
||||||
name: "Desktop Chrome",
|
name: "Desktop Chrome",
|
||||||
|
testIgnore: ["**/*.real.spec.ts"],
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "real-provider",
|
||||||
|
testMatch: ["**/*.real.spec.ts"],
|
||||||
use: { ...devices["Desktop Chrome"] },
|
use: { ...devices["Desktop Chrome"] },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -412,16 +412,21 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||||
return (
|
return (
|
||||||
<UserMessage
|
<UserMessage
|
||||||
|
serverId={resolvedServerId}
|
||||||
|
agentId={agentId}
|
||||||
|
messageId={item.id}
|
||||||
message={item.text}
|
message={item.text}
|
||||||
images={item.images}
|
images={item.images}
|
||||||
attachments={item.attachments}
|
attachments={item.attachments}
|
||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
|
capabilities={agent.capabilities}
|
||||||
|
client={client}
|
||||||
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
isFirstInGroup={layoutItem.isFirstInUserGroup}
|
||||||
isLastInGroup={layoutItem.isLastInUserGroup}
|
isLastInGroup={layoutItem.isLastInUserGroup}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[],
|
[agent.capabilities, agentId, client, resolvedServerId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderAssistantMessageItem = useCallback(
|
const renderAssistantMessageItem = useCallback(
|
||||||
|
|||||||
@@ -353,6 +353,7 @@ describe("dispatchComposerAgentMessage", () => {
|
|||||||
expect(userMessage.images).toEqual([image]);
|
expect(userMessage.images).toEqual([image]);
|
||||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||||
expect(userMessage.id).toBe(call.options.messageId);
|
expect(userMessage.id).toBe(call.options.messageId);
|
||||||
|
expect(userMessage.optimistic).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appends to the existing head when one is present", async () => {
|
it("appends to the existing head when one is present", async () => {
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export async function dispatchComposerAgentMessage(
|
|||||||
id: messageId,
|
id: messageId,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
|
optimistic: true,
|
||||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
...(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 { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
import { isWeb, isNative } from "@/constants/platform";
|
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";
|
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||||
|
|
||||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
|
serverId?: string;
|
||||||
|
agentId?: string;
|
||||||
|
messageId?: string;
|
||||||
message: string;
|
message: string;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
attachments?: AgentAttachment[];
|
attachments?: AgentAttachment[];
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
capabilities?: AgentCapabilityFlags;
|
||||||
|
client?: DaemonClient | null;
|
||||||
isFirstInGroup?: boolean;
|
isFirstInGroup?: boolean;
|
||||||
isLastInGroup?: boolean;
|
isLastInGroup?: boolean;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
@@ -450,10 +458,15 @@ function getUserMessageAttachmentLabel(attachment: AgentAttachment): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const UserMessage = memo(function UserMessage({
|
export const UserMessage = memo(function UserMessage({
|
||||||
|
serverId,
|
||||||
|
agentId,
|
||||||
|
messageId,
|
||||||
message,
|
message,
|
||||||
images = [],
|
images = [],
|
||||||
attachments = [],
|
attachments = [],
|
||||||
timestamp,
|
timestamp,
|
||||||
|
capabilities,
|
||||||
|
client,
|
||||||
isFirstInGroup = true,
|
isFirstInGroup = true,
|
||||||
isLastInGroup = true,
|
isLastInGroup = true,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
@@ -469,10 +482,17 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
() => formatMessageTimestamp(new Date(timestamp)),
|
() => formatMessageTimestamp(new Date(timestamp)),
|
||||||
[timestamp],
|
[timestamp],
|
||||||
);
|
);
|
||||||
|
const rewindMutation = useRewindAgentMutation({ serverId, agentId, client, messageId });
|
||||||
|
|
||||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||||
const getMessageContent = useCallback(() => message, [message]);
|
const getMessageContent = useCallback(() => message, [message]);
|
||||||
|
const handleRewind = useCallback(
|
||||||
|
(input: { mode: RewindMode; rewoundText: string }) => {
|
||||||
|
return rewindMutation.rewindAgent(input);
|
||||||
|
},
|
||||||
|
[rewindMutation],
|
||||||
|
);
|
||||||
|
|
||||||
const containerStyle = useMemo(
|
const containerStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -510,7 +530,7 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={containerStyle}>
|
<View style={containerStyle} testID="user-message">
|
||||||
<View
|
<View
|
||||||
style={userMessageStylesheet.content}
|
style={userMessageStylesheet.content}
|
||||||
onPointerEnter={handlePointerEnter}
|
onPointerEnter={handlePointerEnter}
|
||||||
@@ -549,6 +569,14 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
{hasText ? (
|
{hasText ? (
|
||||||
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
|
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
|
||||||
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
|
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
|
||||||
|
{capabilities ? (
|
||||||
|
<RewindMenu
|
||||||
|
capabilities={capabilities}
|
||||||
|
isPending={rewindMutation.isPending}
|
||||||
|
rewoundText={message}
|
||||||
|
onRewind={handleRewind}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<TurnCopyButton
|
<TurnCopyButton
|
||||||
getContent={getMessageContent}
|
getContent={getMessageContent}
|
||||||
containerStyle={userMessageStylesheet.copyButton}
|
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,
|
id: messageId,
|
||||||
text: message,
|
text: message,
|
||||||
timestamp: new Date(),
|
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
|
// Append to head if streaming (keeps the user message with the current
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
|||||||
commandDraftConfig: DraftCommandConfig | undefined;
|
commandDraftConfig: DraftCommandConfig | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AgentInputDraft {
|
export interface AgentInputDraft {
|
||||||
text: string;
|
text: string;
|
||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
attachments: UserComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
|
|||||||
@@ -99,6 +99,16 @@ function expectSyncErrorSync(state: ReadyState): void {
|
|||||||
expect(state.sync.status).toBe("sync_error");
|
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", () => {
|
describe("deriveAgentScreenViewState", () => {
|
||||||
it("returns boot loading before first interactive paint", () => {
|
it("returns boot loading before first interactive paint", () => {
|
||||||
const memory = createBaseMemory();
|
const memory = createBaseMemory();
|
||||||
@@ -383,6 +393,39 @@ describe("deriveAgentScreenViewState", () => {
|
|||||||
expect(ready.agent.status).toBe("running");
|
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", () => {
|
it("keeps optimistic running status while authoritative agent is initializing", () => {
|
||||||
const memory = createBaseMemory();
|
const memory = createBaseMemory();
|
||||||
const input: AgentScreenMachineInput = {
|
const input: AgentScreenMachineInput = {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
|
|
||||||
export interface AgentScreenAgent {
|
export interface AgentScreenAgent {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
id: string;
|
id: string;
|
||||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||||
cwd: string;
|
cwd: string;
|
||||||
|
capabilities?: AgentCapabilityFlags;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
projectPlacement?: {
|
projectPlacement?: {
|
||||||
checkout?: {
|
checkout?: {
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
|||||||
id: machine.attempt.clientMessageId,
|
id: machine.attempt.clientMessageId,
|
||||||
text: machine.attempt.text,
|
text: machine.attempt.text,
|
||||||
timestamp: machine.attempt.timestamp,
|
timestamp: machine.attempt.timestamp,
|
||||||
|
optimistic: true,
|
||||||
...(machine.attempt.images && machine.attempt.images.length > 0
|
...(machine.attempt.images && machine.attempt.images.length > 0
|
||||||
? { images: machine.attempt.images }
|
? { images: machine.attempt.images }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view
|
|||||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||||
import { Composer } from "@/components/composer";
|
import { Composer } from "@/components/composer";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
|
import { RewindComposerRestoreProvider } from "@/components/rewind/composer-restore";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||||
@@ -23,7 +24,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { isNative, isWeb } from "@/constants/platform";
|
import { isNative, isWeb } from "@/constants/platform";
|
||||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
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 {
|
import {
|
||||||
type AgentScreenAgent,
|
type AgentScreenAgent,
|
||||||
type AgentScreenMissingState,
|
type AgentScreenMissingState,
|
||||||
@@ -69,6 +70,7 @@ interface ChatAgentStateShape {
|
|||||||
id: string | null;
|
id: string | null;
|
||||||
status: Agent["status"] | null;
|
status: Agent["status"] | null;
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
|
capabilities?: Agent["capabilities"];
|
||||||
lastError?: Agent["lastError"] | null;
|
lastError?: Agent["lastError"] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +113,7 @@ function selectChatAgentState(
|
|||||||
id: agent.id,
|
id: agent.id,
|
||||||
status: agent.status,
|
status: agent.status,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
|
capabilities: agent.capabilities,
|
||||||
lastError: agent.lastError ?? null,
|
lastError: agent.lastError ?? null,
|
||||||
archivedAt: agent.archivedAt ?? null,
|
archivedAt: agent.archivedAt ?? null,
|
||||||
requiresAttention: agent.requiresAttention ?? false,
|
requiresAttention: agent.requiresAttention ?? false,
|
||||||
@@ -130,6 +133,7 @@ function buildChatAgentFromState(
|
|||||||
id: state.id,
|
id: state.id,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
cwd: state.cwd,
|
cwd: state.cwd,
|
||||||
|
capabilities: state.capabilities,
|
||||||
lastError: state.lastError ?? null,
|
lastError: state.lastError ?? null,
|
||||||
projectPlacement,
|
projectPlacement,
|
||||||
};
|
};
|
||||||
@@ -945,70 +949,162 @@ function ChatAgentContent({
|
|||||||
effectiveAgent,
|
effectiveAgent,
|
||||||
});
|
});
|
||||||
if (nonReadyView) return nonReadyView;
|
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(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 (
|
return (
|
||||||
<View style={styles.root}>
|
<ChatAgentReadyContent
|
||||||
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
|
serverId={serverId}
|
||||||
<View style={styles.container}>
|
agentId={agentId}
|
||||||
<View style={styles.contentContainer}>
|
isPaneFocused={isPaneFocused}
|
||||||
<ReanimatedAnimated.View style={animatedContentStyle}>
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||||
<AgentStreamSection
|
agentState={agentState}
|
||||||
streamViewRef={streamViewRef}
|
effectiveAgent={effectiveAgent}
|
||||||
serverId={serverId}
|
pendingCreate={pendingCreate}
|
||||||
agentId={agentId}
|
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
||||||
agent={effectiveAgent}
|
canFinalizePendingCreate={canFinalizePendingCreate}
|
||||||
pendingCreate={pendingCreate}
|
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||||
shouldUseOptimisticStream={shouldUseOptimisticStream}
|
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||||
canFinalizePendingCreate={canFinalizePendingCreate}
|
panelToast={panelToast}
|
||||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
streamViewRef={streamViewRef}
|
||||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
animatedContentStyle={animatedContentStyle}
|
||||||
toast={panelToast.api}
|
handleFilesDropped={handleFilesDropped}
|
||||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
handleAddImagesCallback={handleAddImagesCallback}
|
||||||
/>
|
handleComposerHeightChange={handleComposerHeightChange}
|
||||||
</ReanimatedAnimated.View>
|
handleMessageSent={handleMessageSent}
|
||||||
</View>
|
showPendingCreateSubmitLoading={showPendingCreateSubmitLoading}
|
||||||
|
showHistorySyncOverlay={showHistorySyncOverlay}
|
||||||
|
cwd={agentCwd}
|
||||||
|
attentionController={attentionController}
|
||||||
|
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
<AgentComposerSection
|
function ChatAgentReadyContent({
|
||||||
agentId={agentId}
|
serverId,
|
||||||
serverId={serverId}
|
agentId,
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused,
|
||||||
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
isArchivingCurrentAgent,
|
||||||
archivedAt={agentState.archivedAt}
|
agentState,
|
||||||
cwd={agentState.cwd}
|
effectiveAgent,
|
||||||
isSubmitLoading={showPendingCreateSubmitLoading}
|
pendingCreate,
|
||||||
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
shouldUseOptimisticStream,
|
||||||
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
canFinalizePendingCreate,
|
||||||
onAddImages={handleAddImagesCallback}
|
routeBottomAnchorRequest,
|
||||||
onComposerHeightChange={handleComposerHeightChange}
|
hasAppliedAuthoritativeHistory,
|
||||||
onMessageSent={handleMessageSent}
|
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" &&
|
return (
|
||||||
viewState.sync.status === "catching_up" &&
|
<RewindComposerRestoreProvider text={agentInputDraft.text} setText={agentInputDraft.setText}>
|
||||||
viewState.sync.ui === "overlay" ? (
|
<View style={styles.root}>
|
||||||
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
<FileDropZone onFilesDropped={handleFilesDropped} disabled={isArchivingCurrentAgent}>
|
||||||
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
<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>
|
</View>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ToastViewport
|
<AgentComposerSection
|
||||||
toast={panelToast.toast}
|
agentId={agentId}
|
||||||
onDismiss={panelToast.dismiss}
|
serverId={serverId}
|
||||||
placement="panel"
|
isPaneFocused={isPaneFocused}
|
||||||
/>
|
isArchivingCurrentAgent={isArchivingCurrentAgent}
|
||||||
</View>
|
archivedAt={agentState.archivedAt}
|
||||||
</FileDropZone>
|
cwd={cwd}
|
||||||
|
isSubmitLoading={showPendingCreateSubmitLoading}
|
||||||
|
agentInputDraft={agentInputDraft}
|
||||||
|
onAttentionInputFocus={attentionController.clearOnInputFocus}
|
||||||
|
onAttentionPromptSend={attentionController.clearOnPromptSend}
|
||||||
|
onAddImages={handleAddImagesCallback}
|
||||||
|
onComposerHeightChange={handleComposerHeightChange}
|
||||||
|
onMessageSent={handleMessageSent}
|
||||||
|
/>
|
||||||
|
|
||||||
{isArchivingCurrentAgent ? (
|
{showHistorySyncOverlay ? (
|
||||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
<View style={styles.historySyncOverlay} testID="agent-history-overlay">
|
||||||
<ThemedActivityIndicator size="large" uniProps={foregroundColorMapping} />
|
<ThemedActivityIndicator size="large" uniProps={foregroundMutedColorMapping} />
|
||||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
</View>
|
||||||
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
) : null}
|
||||||
</View>
|
|
||||||
) : null}
|
<ToastViewport
|
||||||
</View>
|
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,
|
id: pendingCreate.clientMessageId,
|
||||||
text: pendingCreate.text,
|
text: pendingCreate.text,
|
||||||
timestamp: new Date(pendingCreate.timestamp),
|
timestamp: new Date(pendingCreate.timestamp),
|
||||||
|
optimistic: true,
|
||||||
...(pendingCreate.images && pendingCreate.images.length > 0
|
...(pendingCreate.images && pendingCreate.images.length > 0
|
||||||
? { images: pendingCreate.images }
|
? { images: pendingCreate.images }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -1183,6 +1280,7 @@ function AgentComposerSection({
|
|||||||
archivedAt,
|
archivedAt,
|
||||||
cwd,
|
cwd,
|
||||||
isSubmitLoading,
|
isSubmitLoading,
|
||||||
|
agentInputDraft,
|
||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
onAttentionPromptSend,
|
onAttentionPromptSend,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
@@ -1196,6 +1294,7 @@ function AgentComposerSection({
|
|||||||
archivedAt: Date | null;
|
archivedAt: Date | null;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
isSubmitLoading: boolean;
|
isSubmitLoading: boolean;
|
||||||
|
agentInputDraft: AgentInputDraft;
|
||||||
onAttentionInputFocus: () => void;
|
onAttentionInputFocus: () => void;
|
||||||
onAttentionPromptSend: () => void;
|
onAttentionPromptSend: () => void;
|
||||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||||
@@ -1219,6 +1318,7 @@ function AgentComposerSection({
|
|||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
isSubmitLoading={isSubmitLoading}
|
isSubmitLoading={isSubmitLoading}
|
||||||
|
agentInputDraft={agentInputDraft}
|
||||||
onAttentionInputFocus={onAttentionInputFocus}
|
onAttentionInputFocus={onAttentionInputFocus}
|
||||||
onAttentionPromptSend={onAttentionPromptSend}
|
onAttentionPromptSend={onAttentionPromptSend}
|
||||||
onAddImages={onAddImages}
|
onAddImages={onAddImages}
|
||||||
@@ -1234,6 +1334,7 @@ function ActiveAgentComposer({
|
|||||||
isPaneFocused,
|
isPaneFocused,
|
||||||
cwd,
|
cwd,
|
||||||
isSubmitLoading,
|
isSubmitLoading,
|
||||||
|
agentInputDraft,
|
||||||
onAttentionInputFocus,
|
onAttentionInputFocus,
|
||||||
onAttentionPromptSend,
|
onAttentionPromptSend,
|
||||||
onAddImages,
|
onAddImages,
|
||||||
@@ -1245,6 +1346,7 @@ function ActiveAgentComposer({
|
|||||||
isPaneFocused: boolean;
|
isPaneFocused: boolean;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
isSubmitLoading: boolean;
|
isSubmitLoading: boolean;
|
||||||
|
agentInputDraft: AgentInputDraft;
|
||||||
onAttentionInputFocus: () => void;
|
onAttentionInputFocus: () => void;
|
||||||
onAttentionPromptSend: () => void;
|
onAttentionPromptSend: () => void;
|
||||||
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
onAddImages: (addImages: (images: ImageAttachment[]) => void) => void;
|
||||||
@@ -1270,12 +1372,6 @@ function ActiveAgentComposer({
|
|||||||
[serverId],
|
[serverId],
|
||||||
);
|
);
|
||||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||||
const agentInputDraft = useAgentInputDraft({
|
|
||||||
draftKey: buildDraftStoreKey({
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
|
|||||||
@@ -276,8 +276,17 @@ describe("processTimelineResponse", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
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({
|
const result = processTimelineResponse({
|
||||||
...baseTimelineInput,
|
...baseTimelineInput,
|
||||||
|
currentTail: existingTail,
|
||||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||||
payload: {
|
payload: {
|
||||||
...baseTimelineInput.payload,
|
...baseTimelineInput.payload,
|
||||||
@@ -288,6 +297,8 @@ describe("processTimelineResponse", () => {
|
|||||||
|
|
||||||
expect(result.cursor).toBe(null);
|
expect(result.cursor).toBe(null);
|
||||||
expect(result.cursorChanged).toBe(true);
|
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", () => {
|
it("performs bootstrap tail init with catch-up side effect", () => {
|
||||||
@@ -933,6 +944,34 @@ describe("processAgentStreamEvent", () => {
|
|||||||
expect(result.sideEffects).toEqual([]);
|
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", () => {
|
it("initializes cursor when none exists", () => {
|
||||||
const result = processAgentStreamEvent({
|
const result = processAgentStreamEvent({
|
||||||
...baseStreamInput,
|
...baseStreamInput,
|
||||||
|
|||||||
@@ -623,6 +623,14 @@ export interface AgentStreamReducerEvent {
|
|||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TimelineSequencingGateResult {
|
||||||
|
shouldApplyStreamEvent: boolean;
|
||||||
|
nextTimelineCursor: TimelineCursor | null;
|
||||||
|
cursorChanged: boolean;
|
||||||
|
resetLiveTimeline: boolean;
|
||||||
|
sideEffects: AgentStreamReducerSideEffect[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentStreamReducerAgentSnapshot {
|
export interface AgentStreamReducerAgentSnapshot {
|
||||||
status: AgentLifecycleStatus;
|
status: AgentLifecycleStatus;
|
||||||
updatedAt: Date;
|
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(
|
export function processAgentStreamEvent(
|
||||||
input: ProcessAgentStreamEventInput,
|
input: ProcessAgentStreamEventInput,
|
||||||
): ProcessAgentStreamEventOutput {
|
): ProcessAgentStreamEventOutput {
|
||||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
||||||
input;
|
input;
|
||||||
|
|
||||||
let shouldApplyStreamEvent = true;
|
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Apply stream event to tail/head
|
// Apply stream event to tail/head
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
|
const { tail, head, changedTail, changedHead } = sequencing.shouldApplyStreamEvent
|
||||||
? applyStreamEvent({
|
? applyStreamEvent({
|
||||||
tail: currentTail,
|
tail: sequencing.resetLiveTimeline ? [] : currentTail,
|
||||||
head: currentHead,
|
head: sequencing.resetLiveTimeline ? [] : currentHead,
|
||||||
event,
|
event,
|
||||||
timestamp,
|
timestamp,
|
||||||
source: "live",
|
source: "live",
|
||||||
@@ -771,11 +809,11 @@ export function processAgentStreamEvent(
|
|||||||
head,
|
head,
|
||||||
changedTail,
|
changedTail,
|
||||||
changedHead,
|
changedHead,
|
||||||
cursor: nextTimelineCursor,
|
cursor: sequencing.nextTimelineCursor,
|
||||||
cursorChanged,
|
cursorChanged: sequencing.cursorChanged,
|
||||||
agent: agentPatch,
|
agent: agentPatch,
|
||||||
agentChanged,
|
agentChanged,
|
||||||
sideEffects,
|
sideEffects: sequencing.sideEffects,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import invariant from "tiny-invariant";
|
import invariant from "tiny-invariant";
|
||||||
import { describe, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyStreamEvent,
|
||||||
|
clearOptimisticUserMessages,
|
||||||
hydrateStreamState,
|
hydrateStreamState,
|
||||||
mergeToolCallDetail,
|
mergeToolCallDetail,
|
||||||
reduceStreamUpdate,
|
reduceStreamUpdate,
|
||||||
@@ -309,7 +311,7 @@ describe("stream reducer canonical tool calls", () => {
|
|||||||
const first = hydrateStreamState(updates);
|
const first = hydrateStreamState(updates);
|
||||||
const second = 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");
|
const assistantMessage = first.find((item) => item.kind === "assistant_message");
|
||||||
assert.strictEqual(assistantMessage?.text, "Hello world");
|
assert.strictEqual(assistantMessage?.text, "Hello world");
|
||||||
});
|
});
|
||||||
@@ -735,6 +737,7 @@ describe("stream reducer canonical tool calls", () => {
|
|||||||
id: messageId,
|
id: messageId,
|
||||||
text: "Analyze this image",
|
text: "Analyze this image",
|
||||||
timestamp: new Date("2025-01-01T11:10:00Z"),
|
timestamp: new Date("2025-01-01T11:10:00Z"),
|
||||||
|
optimistic: true,
|
||||||
images: optimisticImages,
|
images: optimisticImages,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -935,4 +938,270 @@ describe("turn lifecycle events", () => {
|
|||||||
["user_message", "user_message"],
|
["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;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
|
optimistic?: true;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
attachments?: AgentAttachment[];
|
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(
|
function appendUserMessage(
|
||||||
state: StreamItem[],
|
state: StreamItem[],
|
||||||
text: string,
|
text: string,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
|
source: StreamUpdateSource,
|
||||||
messageId?: string,
|
messageId?: string,
|
||||||
): StreamItem[] {
|
): StreamItem[] {
|
||||||
const { chunk, hasContent } = normalizeChunk(text);
|
const { chunk, hasContent } = normalizeChunk(text);
|
||||||
@@ -197,32 +220,33 @@ function appendUserMessage(
|
|||||||
|
|
||||||
const chunkSeed = chunk.trim() || chunk;
|
const chunkSeed = chunk.trim() || chunk;
|
||||||
const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
|
const entryId = messageId ?? createUniqueTimelineId(state, "user", chunkSeed, timestamp);
|
||||||
const existingIndex = state.findIndex(
|
const optimisticIndex =
|
||||||
(entry) => entry.kind === "user_message" && entry.id === entryId,
|
source === "live"
|
||||||
);
|
? state.findIndex((entry) => entry.kind === "user_message" && entry.optimistic)
|
||||||
const existing =
|
: -1;
|
||||||
existingIndex >= 0 && state[existingIndex]?.kind === "user_message"
|
const optimistic = optimisticIndex >= 0 ? (state[optimisticIndex] as UserMessageItem) : null;
|
||||||
? state[existingIndex]
|
|
||||||
: null;
|
|
||||||
const preservedImages = existing?.images;
|
|
||||||
|
|
||||||
const nextItem: UserMessageItem = {
|
const nextItem = buildUserMessageItem({
|
||||||
kind: "user_message",
|
|
||||||
id: entryId,
|
id: entryId,
|
||||||
text: chunk,
|
text: chunk,
|
||||||
timestamp,
|
timestamp,
|
||||||
...(preservedImages && preservedImages.length > 0 ? { images: preservedImages } : {}),
|
optimistic,
|
||||||
};
|
});
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
if (optimisticIndex >= 0) {
|
||||||
const next = [...state];
|
const next = [...state];
|
||||||
next[existingIndex] = nextItem;
|
next[optimisticIndex] = nextItem;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...state, nextItem];
|
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(
|
function appendAssistantMessage(
|
||||||
state: StreamItem[],
|
state: StreamItem[],
|
||||||
text: string,
|
text: string,
|
||||||
@@ -664,7 +688,9 @@ function reduceTimelineEvent(
|
|||||||
const item = event.item;
|
const item = event.item;
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "user_message":
|
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":
|
case "assistant_message":
|
||||||
return finalizeActiveThoughts(
|
return finalizeActiveThoughts(
|
||||||
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
|
appendAssistantMessage(state, item.text, timestamp, source, item.messageId),
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import type {
|
|||||||
ProviderDiagnosticResponseMessage,
|
ProviderDiagnosticResponseMessage,
|
||||||
DaemonGetStatusResponse,
|
DaemonGetStatusResponse,
|
||||||
DaemonGetPairingOfferResponse,
|
DaemonGetPairingOfferResponse,
|
||||||
|
AgentRewindResponseMessage,
|
||||||
ListTerminalsResponse,
|
ListTerminalsResponse,
|
||||||
CreateTerminalResponse,
|
CreateTerminalResponse,
|
||||||
SubscribeTerminalResponse,
|
SubscribeTerminalResponse,
|
||||||
@@ -2232,6 +2233,40 @@ export class DaemonClient {
|
|||||||
await this.sendAgentMessage(agentId, text, options);
|
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> {
|
async cancelAgent(agentId: string): Promise<void> {
|
||||||
const requestId = this.createRequestId();
|
const requestId = this.createRequestId();
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
|
|||||||
@@ -2192,9 +2192,10 @@ test("getAgent does not expose committed history internals once manager owns the
|
|||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.recordUserMessage(snapshot.id, "hello boundary", {
|
await manager.appendTimelineItem(snapshot.id, {
|
||||||
|
type: "user_message",
|
||||||
|
text: "hello boundary",
|
||||||
messageId: "msg-boundary-1",
|
messageId: "msg-boundary-1",
|
||||||
emitState: false,
|
|
||||||
});
|
});
|
||||||
await manager.appendTimelineItem(snapshot.id, {
|
await manager.appendTimelineItem(snapshot.id, {
|
||||||
type: "assistant_message",
|
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);
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_750_000_000_000);
|
||||||
try {
|
try {
|
||||||
manager.recordUserMessage(snapshot.id, "hello");
|
await manager.appendTimelineItem(snapshot.id, { type: "user_message", text: "hello" });
|
||||||
const afterMessage = manager.getAgent(snapshot.id);
|
const afterMessage = manager.getAgent(snapshot.id);
|
||||||
expect(afterMessage).toBeDefined();
|
expect(afterMessage).toBeDefined();
|
||||||
const messageUpdatedAt = afterMessage!.updatedAt.getTime();
|
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 () => {
|
test("runAgent assembles finalText from trailing assistant chunks", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
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 () => {
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-keep-user-"));
|
||||||
const storagePath = join(workdir, "agents");
|
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));
|
expect(timeline[1]?.timestamp).toEqual(expect.any(String));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("hydrateTimeline suppresses only matching canonical user_message messageId", async () => {
|
test("provider user_message is recorded from the live stream", 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 () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-prior-record-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-prior-record-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
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 });
|
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir });
|
||||||
|
|
||||||
// No recordUserMessage — run directly
|
|
||||||
await manager.runAgent(snapshot.id, { text: "do something" });
|
await manager.runAgent(snapshot.id, { text: "do something" });
|
||||||
|
|
||||||
const timeline = manager.getTimeline(snapshot.id);
|
const timeline = manager.getTimeline(snapshot.id);
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
|
import { ForegroundRunState, type ForegroundTurnWaiter } from "./foreground-run-state.js";
|
||||||
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||||
import { IMPORTABLE_PROVIDERS } from "./provider-registry.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 RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
||||||
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
||||||
@@ -65,6 +66,9 @@ const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: false,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
type TimeoutResult = "completed" | "timed_out";
|
type TimeoutResult = "completed" | "timed_out";
|
||||||
@@ -132,6 +136,11 @@ export interface SubscribeOptions {
|
|||||||
replayState?: boolean;
|
replayState?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HydrateTimelineOptions {
|
||||||
|
force?: boolean;
|
||||||
|
broadcast?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & {
|
export type ImportablePersistedAgentQueryOptions = ListPersistedAgentsOptions & {
|
||||||
/**
|
/**
|
||||||
* When set, only providers in this set are scanned, in addition to the
|
* When set, only providers in this set are scanned, in addition to the
|
||||||
@@ -220,7 +229,6 @@ interface StreamEventFlags {
|
|||||||
|
|
||||||
interface HandleStreamEventOptions {
|
interface HandleStreamEventOptions {
|
||||||
fromHistory?: boolean;
|
fromHistory?: boolean;
|
||||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ManagedAgentBase {
|
interface ManagedAgentBase {
|
||||||
@@ -374,28 +382,6 @@ function validateAgentId(agentId: string, source: string): string {
|
|||||||
return result.data;
|
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(
|
function buildExplicitTimelineSeedForRegister(
|
||||||
now: Date,
|
now: Date,
|
||||||
options:
|
options:
|
||||||
@@ -1459,39 +1445,6 @@ export class AgentManager {
|
|||||||
return true;
|
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> {
|
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
this.touchUpdatedAt(agent);
|
this.touchUpdatedAt(agent);
|
||||||
@@ -1984,9 +1937,47 @@ export class AgentManager {
|
|||||||
* timeline is empty (e.g., imported agents that have provider history
|
* timeline is empty (e.g., imported agents that have provider history
|
||||||
* on disk but no persisted timeline rows). No-ops if already hydrated.
|
* 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);
|
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> {
|
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
||||||
@@ -2074,19 +2065,6 @@ export class AgentManager {
|
|||||||
return await this.durableTimelineStore.getLastItem(agentId);
|
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(
|
async waitForAgentEvent(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
options?: WaitForAgentOptions,
|
options?: WaitForAgentOptions,
|
||||||
@@ -2627,20 +2605,53 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hydrateTimelineFromLegacyProviderHistory(agent: ActiveManagedAgent): Promise<void> {
|
private async hydrateTimelineFromLegacyProviderHistory(
|
||||||
if (agent.historyPrimed) {
|
agent: ActiveManagedAgent,
|
||||||
|
options?: HydrateTimelineOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
if (agent.historyPrimed && !options?.force) {
|
||||||
return;
|
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;
|
agent.historyPrimed = true;
|
||||||
const canonicalUserMessagesById = this.timelineStore.getCanonicalUserMessagesById(agent.id);
|
|
||||||
try {
|
try {
|
||||||
for await (const event of agent.session.streamHistory()) {
|
for await (const event of agent.session.streamHistory()) {
|
||||||
if (event.type !== "timeline") {
|
if (event.type !== "timeline") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (isDuplicateLegacyUserMessage(event.item, canonicalUserMessagesById)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
this.recordTimeline(
|
this.recordTimeline(
|
||||||
agent.id,
|
agent.id,
|
||||||
event.item,
|
event.item,
|
||||||
@@ -2882,42 +2893,11 @@ export class AgentManager {
|
|||||||
private async onStreamTimelineEvent(params: {
|
private async onStreamTimelineEvent(params: {
|
||||||
agent: ActiveManagedAgent;
|
agent: ActiveManagedAgent;
|
||||||
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
||||||
options:
|
options: { fromHistory?: boolean } | undefined;
|
||||||
| {
|
|
||||||
fromHistory?: boolean;
|
|
||||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
isForegroundEvent: boolean;
|
isForegroundEvent: boolean;
|
||||||
flags: StreamEventFlags;
|
flags: StreamEventFlags;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { agent, event, options, isForegroundEvent, flags } = params;
|
const { agent, event, options, 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.fromHistory) {
|
if (options?.fromHistory) {
|
||||||
this.recordTimeline(
|
this.recordTimeline(
|
||||||
@@ -2971,12 +2951,7 @@ export class AgentManager {
|
|||||||
event: Extract<AgentStreamEvent, { type: "turn_failed" }>;
|
event: Extract<AgentStreamEvent, { type: "turn_failed" }>;
|
||||||
eventTurnId: string | undefined;
|
eventTurnId: string | undefined;
|
||||||
isForegroundEvent: boolean;
|
isForegroundEvent: boolean;
|
||||||
options:
|
options: { fromHistory?: boolean } | undefined;
|
||||||
| {
|
|
||||||
fromHistory?: boolean;
|
|
||||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { agent, event, eventTurnId, isForegroundEvent, options } = params;
|
const { agent, event, eventTurnId, isForegroundEvent, options } = params;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -3139,10 +3114,7 @@ export class AgentManager {
|
|||||||
agent: ActiveManagedAgent,
|
agent: ActiveManagedAgent,
|
||||||
provider: AgentProvider,
|
provider: AgentProvider,
|
||||||
message: string,
|
message: string,
|
||||||
options?: {
|
options?: { fromHistory?: boolean },
|
||||||
fromHistory?: boolean;
|
|
||||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
|
||||||
},
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (options?.fromHistory) {
|
if (options?.fromHistory) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ export function buildStoredAgentPayload(
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: false,
|
supportsReasoningStream: false,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: false,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const createdAt = new Date(record.createdAt);
|
const createdAt = new Date(record.createdAt);
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export interface SendPromptToAgentParams {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
/** Prompt to dispatch to the provider (may include image blocks or wrapped text). */
|
/** Prompt to dispatch to the provider (may include image blocks or wrapped text). */
|
||||||
prompt: AgentPromptInput;
|
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;
|
userMessageText?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
runOptions?: AgentRunOptions;
|
runOptions?: AgentRunOptions;
|
||||||
@@ -131,17 +131,17 @@ export interface SendPromptToAgentParams {
|
|||||||
*/
|
*/
|
||||||
unarchive?: boolean;
|
unarchive?: boolean;
|
||||||
/**
|
/**
|
||||||
* Default true. When false, the prompt is dispatched to the provider but
|
* Default true. When false, the prompt is dispatched to the provider as an
|
||||||
* no user-message turn is added to the timeline — the app shows nothing.
|
* invisible/system prompt — the app shows no user-message turn.
|
||||||
* Use false for system-injected prompts.
|
* Use false for system-injected prompts.
|
||||||
*/
|
*/
|
||||||
recordUserMessage?: boolean;
|
userMessageVisible?: boolean;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full send-prompt orchestration: (optional unarchive) → load → (optional
|
* 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,
|
* 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
|
* chat mentions, notify-on-finish) MUST go through this so behavior can never
|
||||||
@@ -154,10 +154,10 @@ export async function sendPromptToAgent(
|
|||||||
params: SendPromptToAgentParams,
|
params: SendPromptToAgentParams,
|
||||||
): Promise<{ outOfBand: boolean }> {
|
): Promise<{ outOfBand: boolean }> {
|
||||||
const unarchive = params.unarchive ?? true;
|
const unarchive = params.unarchive ?? true;
|
||||||
const recordUserMessage = params.recordUserMessage ?? true;
|
const userMessageVisible = params.userMessageVisible ?? true;
|
||||||
|
|
||||||
if (recordUserMessage && params.userMessageText === undefined) {
|
if (userMessageVisible && params.userMessageText === undefined) {
|
||||||
throw new Error("userMessageText is required when recordUserMessage is true");
|
throw new Error("userMessageText is required when userMessageVisible is true");
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await params.agentStorage.get(params.agentId);
|
const record = await params.agentStorage.get(params.agentId);
|
||||||
@@ -178,17 +178,6 @@ export async function sendPromptToAgent(
|
|||||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
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, {
|
return startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
|
||||||
replaceRunning: true,
|
replaceRunning: true,
|
||||||
runOptions: params.runOptions,
|
runOptions: params.runOptions,
|
||||||
@@ -225,7 +214,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
|||||||
agentId: callerAgentId,
|
agentId: callerAgentId,
|
||||||
prompt: formatSystemNotificationPrompt(body),
|
prompt: formatSystemNotificationPrompt(body),
|
||||||
unarchive: false,
|
unarchive: false,
|
||||||
recordUserMessage: false,
|
userMessageVisible: false,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ export interface AgentCapabilityFlags {
|
|||||||
supportsMcpServers: boolean;
|
supportsMcpServers: boolean;
|
||||||
supportsReasoningStream: boolean;
|
supportsReasoningStream: boolean;
|
||||||
supportsToolInvocations: boolean;
|
supportsToolInvocations: boolean;
|
||||||
|
supportsRewindConversation?: boolean;
|
||||||
|
supportsRewindFiles?: boolean;
|
||||||
|
supportsRewindBoth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentPersistenceHandle {
|
export interface AgentPersistenceHandle {
|
||||||
@@ -144,6 +147,7 @@ export interface AgentRunOptions {
|
|||||||
outputSchema?: unknown;
|
outputSchema?: unknown;
|
||||||
resumeFrom?: AgentPersistenceHandle;
|
resumeFrom?: AgentPersistenceHandle;
|
||||||
maxThinkingTokens?: number;
|
maxThinkingTokens?: number;
|
||||||
|
messageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentUsage {
|
export interface AgentUsage {
|
||||||
@@ -545,6 +549,9 @@ export interface AgentSession {
|
|||||||
setModel?(modelId: string | null): Promise<void>;
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||||
setFeature?(featureId: string, value: unknown): 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
|
* Out-of-band prompt handler. When non-null, the manager runs the returned
|
||||||
* handler instead of allocating a turn. The handler emits stream events
|
* handler instead of allocating a turn. The handler emits stream events
|
||||||
|
|||||||
@@ -56,10 +56,6 @@ export interface AgentTimelineStore {
|
|||||||
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
|
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
|
||||||
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
|
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
|
||||||
getLastAssistantMessage(agentId: string): Promise<string | null>;
|
getLastAssistantMessage(agentId: string): Promise<string | null>;
|
||||||
hasCommittedUserMessage(
|
|
||||||
agentId: string,
|
|
||||||
options: { messageId: string; text: string },
|
|
||||||
): Promise<boolean>;
|
|
||||||
deleteAgent(agentId: string): Promise<void>;
|
deleteAgent(agentId: string): Promise<void>;
|
||||||
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
|
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,14 +26,6 @@ function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
|
|||||||
return { ...row };
|
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 {
|
interface FetchContext {
|
||||||
state: AgentTimelineState;
|
state: AgentTimelineState;
|
||||||
direction: NonNullable<AgentTimelineFetchOptions["direction"]>;
|
direction: NonNullable<AgentTimelineFetchOptions["direction"]>;
|
||||||
@@ -282,35 +274,6 @@ export class InMemoryAgentTimelineStore {
|
|||||||
return chunks.toReversed().join("");
|
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 {
|
private requireState(agentId: string): AgentTimelineState {
|
||||||
const state = this.states.get(agentId);
|
const state = this.states.get(agentId);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ function buildAgentManagerSpies() {
|
|||||||
return {
|
return {
|
||||||
createAgent: vi.fn(),
|
createAgent: vi.fn(),
|
||||||
waitForAgentEvent: vi.fn(),
|
waitForAgentEvent: vi.fn(),
|
||||||
recordUserMessage: vi.fn(),
|
|
||||||
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||||
|
|||||||
@@ -1089,14 +1089,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
logger: childLogger,
|
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 {
|
try {
|
||||||
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
|
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
|
||||||
if (notifyOnFinish && callerAgentId) {
|
if (notifyOnFinish && callerAgentId) {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe("createProviderEnv", () => {
|
|||||||
expect(env.PATH).toBe("/custom/path");
|
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 = {
|
const base = {
|
||||||
PATH: "/usr/bin",
|
PATH: "/usr/bin",
|
||||||
CLAUDECODE: "1",
|
CLAUDECODE: "1",
|
||||||
@@ -102,7 +102,7 @@ describe("createProviderEnv", () => {
|
|||||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined();
|
||||||
expect(env.CLAUDE_CODE_SSE_PORT).toBeUndefined();
|
expect(env.CLAUDE_CODE_SSE_PORT).toBeUndefined();
|
||||||
expect(env.CLAUDE_AGENT_SDK_VERSION).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_ENTRYPOINT",
|
||||||
"CLAUDE_CODE_SSE_PORT",
|
"CLAUDE_CODE_SSE_PORT",
|
||||||
"CLAUDE_AGENT_SDK_VERSION",
|
"CLAUDE_AGENT_SDK_VERSION",
|
||||||
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ProviderEnvOptions {
|
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 {
|
return {
|
||||||
provider,
|
provider,
|
||||||
id: inner.id,
|
id: inner.id,
|
||||||
@@ -360,6 +360,10 @@ function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): Agen
|
|||||||
setModel: inner.setModel?.bind(inner),
|
setModel: inner.setModel?.bind(inner),
|
||||||
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
||||||
setFeature: inner.setFeature?.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,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: false,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
|
||||||
|
|||||||
@@ -1519,6 +1519,12 @@ describe("ClaudeAgentSession context window usage", () => {
|
|||||||
const result = await session.run("turn");
|
const result = await session.run("turn");
|
||||||
await session.close();
|
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 { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "../provider-runner.js";
|
||||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||||
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.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 { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -191,6 +192,15 @@ interface PersistedTimelineEntry {
|
|||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClaudeRewindTurnAnchor {
|
||||||
|
userMessageId: string;
|
||||||
|
assistantMessageId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClaudeConversationRewindTarget =
|
||||||
|
| { kind: "fresh-session" }
|
||||||
|
| { kind: "fork"; messageId: string };
|
||||||
|
|
||||||
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: true,
|
supportsSessionPersistence: true,
|
||||||
@@ -198,6 +208,9 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: true,
|
||||||
|
supportsRewindFiles: true,
|
||||||
|
supportsRewindBoth: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MODES: AgentMode[] = [
|
const DEFAULT_MODES: AgentMode[] = [
|
||||||
@@ -1046,7 +1059,7 @@ class TimelineAssembler {
|
|||||||
!isClaudeTranscriptNoiseText(nextAssistantText)
|
!isClaudeTranscriptNoiseText(nextAssistantText)
|
||||||
) {
|
) {
|
||||||
state.emittedAssistantLength = state.assistantText.length;
|
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);
|
const nextReasoningText = state.reasoningText.slice(state.emittedReasoningLength);
|
||||||
@@ -1157,7 +1170,25 @@ function isSyntheticUserEntry(entry: unknown): boolean {
|
|||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
return false;
|
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 {
|
function firstTrimmedString(sources: readonly unknown[]): string | null {
|
||||||
@@ -1170,6 +1201,15 @@ function firstTrimmedString(sources: readonly unknown[]): string | null {
|
|||||||
return 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 {
|
export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
||||||
const root = toObjectRecord(message) ?? {};
|
const root = toObjectRecord(message) ?? {};
|
||||||
const messageType = readTrimmedString(root.type);
|
const messageType = readTrimmedString(root.type);
|
||||||
@@ -1177,7 +1217,10 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
|
|||||||
const streamEventMessage = toObjectRecord(streamEvent?.message);
|
const streamEventMessage = toObjectRecord(streamEvent?.message);
|
||||||
const messageContainer = toObjectRecord(root.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 {
|
return {
|
||||||
taskId: firstTrimmedString([
|
taskId: firstTrimmedString([
|
||||||
@@ -1566,7 +1609,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private queryPumpPromise: Promise<void> | null = null;
|
private queryPumpPromise: Promise<void> | null = null;
|
||||||
private queryRestartNeeded = false;
|
private queryRestartNeeded = false;
|
||||||
private pendingInterruptAbort = false;
|
private pendingInterruptAbort = false;
|
||||||
private lastForegroundPromptText: string | null = null;
|
|
||||||
private foregroundHasVisibleActivity = false;
|
private foregroundHasVisibleActivity = false;
|
||||||
private activeTurnHasAssistantText = false;
|
private activeTurnHasAssistantText = false;
|
||||||
private lastContextWindowUsedTokens: number | undefined;
|
private lastContextWindowUsedTokens: number | undefined;
|
||||||
@@ -1574,6 +1616,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private lastStreamRequestInputTokens: number | undefined;
|
private lastStreamRequestInputTokens: number | undefined;
|
||||||
private lastStreamRequestOutputTokens: number | undefined;
|
private lastStreamRequestOutputTokens: number | undefined;
|
||||||
private userMessageIds: string[] = [];
|
private userMessageIds: string[] = [];
|
||||||
|
private readonly emittedUserMessageIds = new Set<string>();
|
||||||
|
private readonly rewindTurnAnchors: ClaudeRewindTurnAnchor[] = [];
|
||||||
|
private pendingFreshSessionId: string | null = null;
|
||||||
private recentStderr = "";
|
private recentStderr = "";
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
|
||||||
@@ -1689,7 +1734,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sdkMessage = this.toSdkUserMessage(prompt);
|
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");
|
const turnId = this.createTurnId("foreground");
|
||||||
this.activeForegroundTurnId = turnId;
|
this.activeForegroundTurnId = turnId;
|
||||||
this.foregroundHasVisibleActivity = false;
|
this.foregroundHasVisibleActivity = false;
|
||||||
@@ -1727,6 +1774,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.startQueryPump();
|
this.startQueryPump();
|
||||||
this.input.push(sdkMessage);
|
this.input.push(sdkMessage);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.activeForegroundTurnId === turnId) {
|
||||||
|
this.emitSubmittedUserMessage(sdkMessage, turnId);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.finishForegroundTurn(
|
this.finishForegroundTurn(
|
||||||
this.buildTurnFailedEvent(error instanceof Error ? error.message : "Claude stream failed"),
|
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));
|
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 {
|
private resolveSlashCommandInvocation(prompt: AgentPromptInput): SlashCommandInvocation | null {
|
||||||
if (typeof prompt !== "string") {
|
if (typeof prompt !== "string") {
|
||||||
return null;
|
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) {
|
for (let idx = this.persistedHistory.length - 1; idx >= 0; idx -= 1) {
|
||||||
const entry = this.persistedHistory[idx];
|
const entry = this.persistedHistory[idx];
|
||||||
if (entry?.item.type === "user_message") {
|
if (entry?.item.type === "user_message") {
|
||||||
@@ -2167,35 +2245,49 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readUserMessageIdsFromHistoryFile(): string[] {
|
private rebindConversationSession(sessionId: string): void {
|
||||||
if (!this.claudeSessionId) {
|
const oldSessionId = this.claudeSessionId;
|
||||||
return [];
|
this.claudeSessionId = sessionId;
|
||||||
}
|
this.pendingFreshSessionId = null;
|
||||||
const historyPath = this.resolveHistoryPath(this.claudeSessionId);
|
this.persistence = null;
|
||||||
if (!historyPath || !fs.existsSync(historyPath)) {
|
this.cachedRuntimeInfo = null;
|
||||||
return [];
|
this.queryRestartNeeded = true;
|
||||||
}
|
this.persistedHistory = [];
|
||||||
try {
|
this.historyPending = false;
|
||||||
const ids: string[] = [];
|
this.userMessageIds = [];
|
||||||
const content = fs.readFileSync(historyPath, "utf8");
|
this.emittedUserMessageIds.clear();
|
||||||
for (const line of content.split(/\n+/)) {
|
this.rewindTurnAnchors.length = 0;
|
||||||
const trimmed = line.trim();
|
this.loadPersistedHistory(sessionId);
|
||||||
if (!trimmed) continue;
|
if (oldSessionId && oldSessionId !== sessionId) {
|
||||||
try {
|
this.dispatchEvents([
|
||||||
const entry = JSON.parse(trimmed);
|
{
|
||||||
if (entry?.type === "user" && typeof entry.uuid === "string") {
|
type: "timeline",
|
||||||
ids.push(entry.uuid);
|
provider: "claude",
|
||||||
}
|
item: this.createClaudeSessionChangedNotice(oldSessionId, sessionId),
|
||||||
} catch {
|
},
|
||||||
// ignore malformed lines
|
{
|
||||||
}
|
type: "thread_started",
|
||||||
}
|
provider: "claude",
|
||||||
return ids;
|
sessionId,
|
||||||
} catch {
|
},
|
||||||
return [];
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private rememberUserMessageId(messageId: string | null | undefined): void {
|
||||||
if (typeof messageId !== "string" || messageId.length === 0) {
|
if (typeof messageId !== "string" || messageId.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -2207,6 +2299,92 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.userMessageIds.push(messageId);
|
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> {
|
private async ensureQuery(): Promise<Query> {
|
||||||
if (this.query && !this.queryRestartNeeded) {
|
if (this.query && !this.queryRestartNeeded) {
|
||||||
return this.query;
|
return this.query;
|
||||||
@@ -2353,6 +2531,13 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
"Resolved Claude executable",
|
"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 = {
|
const base: ClaudeOptions = {
|
||||||
cwd: this.config.cwd,
|
cwd: this.config.cwd,
|
||||||
includePartialMessages: true,
|
includePartialMessages: true,
|
||||||
@@ -2380,7 +2565,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
enableFileCheckpointing: true,
|
enableFileCheckpointing: true,
|
||||||
// If we have a session ID from a previous query (e.g., after interrupt),
|
// If we have a session ID from a previous query (e.g., after interrupt),
|
||||||
// resume that session to continue the conversation history.
|
// resume that session to continue the conversation history.
|
||||||
...(this.claudeSessionId ? { resume: this.claudeSessionId } : {}),
|
...sessionBinding,
|
||||||
...(thinking ? { thinking } : {}),
|
...(thinking ? { thinking } : {}),
|
||||||
...(effort ? { effort } : {}),
|
...(effort ? { effort } : {}),
|
||||||
...extraClaudeOptions,
|
...extraClaudeOptions,
|
||||||
@@ -2396,7 +2581,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
base.model = this.config.model;
|
base.model = this.config.model;
|
||||||
}
|
}
|
||||||
this.lastOptionsModel = base.model ?? null;
|
this.lastOptionsModel = base.model ?? null;
|
||||||
if (this.claudeSessionId) {
|
if (this.claudeSessionId && !this.pendingFreshSessionId) {
|
||||||
base.resume = this.claudeSessionId;
|
base.resume = this.claudeSessionId;
|
||||||
}
|
}
|
||||||
if (this.runtimeSettings?.disallowedTools?.length) {
|
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(
|
private async executeRewindTurn(
|
||||||
_turnId: string,
|
_turnId: string,
|
||||||
invocation: SlashCommandInvocation,
|
invocation: SlashCommandInvocation,
|
||||||
@@ -2630,7 +2805,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
this.notifySubscribers(event);
|
this.notifySubscribers(event);
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
this.lastForegroundPromptText = null;
|
|
||||||
this.cancelCurrentTurn = null;
|
this.cancelCurrentTurn = null;
|
||||||
this.activeTurnHasAssistantText = false;
|
this.activeTurnHasAssistantText = false;
|
||||||
this.syncTurnState("foreground turn terminal");
|
this.syncTurnState("foreground turn terminal");
|
||||||
@@ -2646,7 +2820,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (terminalSeen) {
|
if (terminalSeen) {
|
||||||
if (this.activeForegroundTurnId) {
|
if (this.activeForegroundTurnId) {
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
this.lastForegroundPromptText = null;
|
|
||||||
this.cancelCurrentTurn = null;
|
this.cancelCurrentTurn = null;
|
||||||
this.activeTurnHasAssistantText = false;
|
this.activeTurnHasAssistantText = false;
|
||||||
this.syncTurnState("foreground turn terminal");
|
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 {
|
private shouldSuppressStaleResult(message: SDKMessage): boolean {
|
||||||
// Suppress stale results from interrupted requests. The cancel path already
|
// Suppress stale results from interrupted requests. The cancel path already
|
||||||
// emitted the terminal event; this result is leftover from the killed API
|
// 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 turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null;
|
||||||
const identifiers = readEventIdentifiers(message);
|
const identifiers = readEventIdentifiers(message);
|
||||||
|
this.rememberTranscriptProgress(message, readTranscriptUuid(message));
|
||||||
|
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
@@ -2895,12 +3057,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}) satisfies AgentStreamEvent,
|
}) satisfies AgentStreamEvent,
|
||||||
);
|
);
|
||||||
|
|
||||||
// User message dedup: suppress echoed user messages that match the foreground prompt
|
const events = [...messageEvents, ...assistantTimelineEvents];
|
||||||
const filteredMessageEvents = messageEvents.filter(
|
|
||||||
(event) => !this.isEchoedForegroundUserMessage(event),
|
|
||||||
);
|
|
||||||
|
|
||||||
const events = [...filteredMessageEvents, ...assistantTimelineEvents];
|
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return;
|
return;
|
||||||
@@ -3013,20 +3170,22 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
const sessionCapture = this.captureSessionIdFromMessage(message);
|
if (message.type !== "system") {
|
||||||
if (sessionCapture.notice) {
|
const sessionCapture = this.captureSessionIdFromMessage(message);
|
||||||
events.push({
|
if (sessionCapture.notice) {
|
||||||
type: "timeline",
|
events.push({
|
||||||
provider: "claude",
|
type: "timeline",
|
||||||
item: sessionCapture.notice,
|
provider: "claude",
|
||||||
});
|
item: sessionCapture.notice,
|
||||||
}
|
});
|
||||||
if (sessionCapture.threadStartedSessionId) {
|
}
|
||||||
events.push({
|
if (sessionCapture.threadStartedSessionId) {
|
||||||
type: "thread_started",
|
events.push({
|
||||||
provider: "claude",
|
type: "thread_started",
|
||||||
sessionId: sessionCapture.threadStartedSessionId,
|
provider: "claude",
|
||||||
});
|
sessionId: sessionCapture.threadStartedSessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
@@ -3059,6 +3218,25 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
return events;
|
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(
|
private appendSystemMessageEvents(
|
||||||
message: Extract<SDKMessage, { type: "system" }>,
|
message: Extract<SDKMessage, { type: "system" }>,
|
||||||
events: AgentStreamEvent[],
|
events: AgentStreamEvent[],
|
||||||
@@ -3162,7 +3340,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
const messageId =
|
const messageId =
|
||||||
typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
|
typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
|
||||||
|
if (messageId && this.emittedUserMessageIds.has(messageId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.rememberUserMessageId(messageId);
|
this.rememberUserMessageId(messageId);
|
||||||
|
this.rememberEmittedUserMessageId(messageId);
|
||||||
const content = message.message?.content;
|
const content = message.message?.content;
|
||||||
const taskNotificationItem = mapTaskNotificationUserContentToToolCall({
|
const taskNotificationItem = mapTaskNotificationUserContentToToolCall({
|
||||||
content,
|
content,
|
||||||
@@ -3293,10 +3475,12 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
if (this.claudeSessionId === null) {
|
if (this.claudeSessionId === null) {
|
||||||
this.claudeSessionId = sessionId;
|
this.claudeSessionId = sessionId;
|
||||||
|
this.pendingFreshSessionId = null;
|
||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
return { threadStartedSessionId: sessionId, notice: null };
|
return { threadStartedSessionId: sessionId, notice: null };
|
||||||
}
|
}
|
||||||
if (this.claudeSessionId === sessionId) {
|
if (this.claudeSessionId === sessionId) {
|
||||||
|
this.pendingFreshSessionId = null;
|
||||||
return { threadStartedSessionId: null, notice: null };
|
return { threadStartedSessionId: null, notice: null };
|
||||||
}
|
}
|
||||||
const oldSessionId = this.claudeSessionId;
|
const oldSessionId = this.claudeSessionId;
|
||||||
@@ -3308,6 +3492,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
"Claude session ID changed in message; accepting new session",
|
"Claude session ID changed in message; accepting new session",
|
||||||
);
|
);
|
||||||
this.claudeSessionId = sessionId;
|
this.claudeSessionId = sessionId;
|
||||||
|
this.pendingFreshSessionId = null;
|
||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
return {
|
return {
|
||||||
threadStartedSessionId: sessionId,
|
threadStartedSessionId: sessionId,
|
||||||
@@ -3338,9 +3523,11 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
if (existingSessionId === null) {
|
if (existingSessionId === null) {
|
||||||
this.claudeSessionId = newSessionId;
|
this.claudeSessionId = newSessionId;
|
||||||
|
this.pendingFreshSessionId = null;
|
||||||
threadStartedSessionId = newSessionId;
|
threadStartedSessionId = newSessionId;
|
||||||
this.logger.debug({ sessionId: newSessionId }, "Claude session ID set for the first time");
|
this.logger.debug({ sessionId: newSessionId }, "Claude session ID set for the first time");
|
||||||
} else if (existingSessionId === newSessionId) {
|
} else if (existingSessionId === newSessionId) {
|
||||||
|
this.pendingFreshSessionId = null;
|
||||||
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
|
this.logger.debug({ sessionId: newSessionId }, "Claude session ID unchanged (same value)");
|
||||||
} else {
|
} else {
|
||||||
// Session ID changed in an init message (e.g. a hook restarted Claude
|
// 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",
|
"Claude session ID changed in init message; accepting new session",
|
||||||
);
|
);
|
||||||
this.claudeSessionId = newSessionId;
|
this.claudeSessionId = newSessionId;
|
||||||
|
this.pendingFreshSessionId = null;
|
||||||
threadStartedSessionId = newSessionId;
|
threadStartedSessionId = newSessionId;
|
||||||
notice = this.createClaudeSessionChangedNotice(existingSessionId, newSessionId);
|
notice = this.createClaudeSessionChangedNotice(existingSessionId, newSessionId);
|
||||||
}
|
}
|
||||||
@@ -3703,12 +3891,22 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (entry.isSidechain) {
|
if (entry.isSidechain) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (entry.type === "user" && typeof entry.uuid === "string") {
|
|
||||||
this.rememberUserMessageId(entry.uuid);
|
|
||||||
}
|
|
||||||
|
|
||||||
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
||||||
const items = this.convertHistoryEntry(entry);
|
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) {
|
if (items.length > 0) {
|
||||||
timeline.push(
|
timeline.push(
|
||||||
...items.map((item) => ({
|
...items.map((item) => ({
|
||||||
@@ -3722,10 +3920,25 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private resolveHistoryPath(sessionId: string): string | null {
|
private resolveHistoryPath(sessionId: string): string | null {
|
||||||
const cwd = this.config.cwd;
|
const cwd = this.config.cwd;
|
||||||
if (!cwd) return null;
|
if (!cwd) return null;
|
||||||
const sanitized = sanitizeClaudeProjectPath(cwd);
|
|
||||||
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||||
const dir = path.join(configDir, "projects", sanitized);
|
const candidates = [cwd];
|
||||||
return path.join(dir, `${sessionId}.jsonl`);
|
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[] {
|
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
|
||||||
@@ -4409,6 +4622,25 @@ interface ClaudeHistoryEntry {
|
|||||||
[key: string]: unknown;
|
[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(
|
function convertClaudeHistoryEntryPreamble(
|
||||||
entry: ClaudeHistoryEntry,
|
entry: ClaudeHistoryEntry,
|
||||||
): { shortCircuit: AgentTimelineItem[] } | { proceed: { content: unknown } } {
|
): { shortCircuit: AgentTimelineItem[] } | { proceed: { content: unknown } } {
|
||||||
@@ -4504,7 +4736,7 @@ export function convertClaudeHistoryEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (entry.type === "assistant" && contentValue) {
|
if (entry.type === "assistant" && contentValue) {
|
||||||
return mapBlocks(contentValue);
|
return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
return timeline;
|
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 { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
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 {
|
import {
|
||||||
createFakeCodexAppServer,
|
createFakeCodexAppServer,
|
||||||
type FakeCodexAppServer,
|
type FakeCodexAppServer,
|
||||||
@@ -74,7 +74,7 @@ function createSessionHarness(
|
|||||||
const appServer = createFakeCodexAppServer({
|
const appServer = createFakeCodexAppServer({
|
||||||
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
|
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
|
||||||
});
|
});
|
||||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
const session = new CodexAppServerAgentSession(
|
||||||
{ ...config, provider: CODEX_PROVIDER },
|
{ ...config, provider: CODEX_PROVIDER },
|
||||||
null,
|
null,
|
||||||
options.logger ?? createTestLogger(),
|
options.logger ?? createTestLogger(),
|
||||||
|
|||||||
@@ -15,12 +15,20 @@ import type {
|
|||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
} from "../agent-sdk-types.js";
|
} from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
__codexAppServerInternals,
|
buildCodexAppServerEnv,
|
||||||
CodexAppServerAgentClient,
|
CodexAppServerAgentClient,
|
||||||
|
CodexAppServerAgentSession,
|
||||||
codexAppServerTurnInputFromPrompt,
|
codexAppServerTurnInputFromPrompt,
|
||||||
|
listCodexSkills,
|
||||||
|
mapCodexPatchNotificationToToolCall,
|
||||||
|
mapCodexPlanToToolCall,
|
||||||
|
normalizeCodexOutputSchema,
|
||||||
|
toAgentUsage,
|
||||||
} from "./codex-app-server-agent.js";
|
} from "./codex-app-server-agent.js";
|
||||||
|
import { CodexAppServerClient } from "./codex/app-server-transport.js";
|
||||||
import {
|
import {
|
||||||
createFakeCodexAppServer,
|
createFakeCodexAppServer,
|
||||||
|
type FakeCodexAppServer,
|
||||||
waitForNextPermission,
|
waitForNextPermission,
|
||||||
} from "./codex/test-utils/fake-app-server.js";
|
} from "./codex/test-utils/fake-app-server.js";
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
@@ -76,7 +84,7 @@ function createSession(
|
|||||||
configOverrides: Partial<AgentSessionConfig> = {},
|
configOverrides: Partial<AgentSessionConfig> = {},
|
||||||
options: { goalsEnabled?: boolean; autoReviewEnabled?: boolean } = {},
|
options: { goalsEnabled?: boolean; autoReviewEnabled?: boolean } = {},
|
||||||
): CodexTestSession {
|
): CodexTestSession {
|
||||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
const session = new CodexAppServerAgentSession(
|
||||||
createConfig(configOverrides),
|
createConfig(configOverrides),
|
||||||
null,
|
null,
|
||||||
createTestLogger(),
|
createTestLogger(),
|
||||||
@@ -106,6 +114,25 @@ function markdownImageSource(markdown: string): string {
|
|||||||
return match[1].replace(/\\\)/g, ")");
|
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>;
|
type CapturedFakeCodexRecord = Record<string, unknown>;
|
||||||
|
|
||||||
async function runCustomCodexProviderTurn(
|
async function runCustomCodexProviderTurn(
|
||||||
@@ -380,7 +407,7 @@ describe("Codex app-server provider", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
const session = new CodexAppServerAgentSession(
|
||||||
createConfig({ thinkingOptionId: "medium" }),
|
createConfig({ thinkingOptionId: "medium" }),
|
||||||
null,
|
null,
|
||||||
createTestLogger(),
|
createTestLogger(),
|
||||||
@@ -411,7 +438,7 @@ describe("Codex app-server provider", () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
const session = new CodexAppServerAgentSession(
|
||||||
createConfig({ thinkingOptionId: "medium" }),
|
createConfig({ thinkingOptionId: "medium" }),
|
||||||
null,
|
null,
|
||||||
createTestLogger(),
|
createTestLogger(),
|
||||||
@@ -437,7 +464,7 @@ describe("Codex app-server provider", () => {
|
|||||||
child.exitCode = null;
|
child.exitCode = null;
|
||||||
child.signalCode = null;
|
child.signalCode = null;
|
||||||
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
|
child.kill = vi.fn(() => true) as ChildProcessWithoutNullStreams["kill"];
|
||||||
const client = new __codexAppServerInternals.CodexAppServerClient(child, createTestLogger());
|
const client = new CodexAppServerClient(child, createTestLogger());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const disposePromise = client.dispose();
|
const disposePromise = client.dispose();
|
||||||
@@ -459,7 +486,7 @@ describe("Codex app-server provider", () => {
|
|||||||
"collaborationMode/list": () => ({ data: [] }),
|
"collaborationMode/list": () => ({ data: [] }),
|
||||||
"skills/list": () => ({ data: [] }),
|
"skills/list": () => ({ data: [] }),
|
||||||
});
|
});
|
||||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
const session = new CodexAppServerAgentSession(
|
||||||
createConfig({ cwd: "/workspace/project" }),
|
createConfig({ cwd: "/workspace/project" }),
|
||||||
null,
|
null,
|
||||||
createTestLogger(),
|
createTestLogger(),
|
||||||
@@ -507,6 +534,32 @@ describe("Codex app-server provider", () => {
|
|||||||
await session.close();
|
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 () => {
|
test("configures Codex app-server to use a custom provider base URL", async () => {
|
||||||
const capturedRequests = await runCustomCodexProviderTurn(
|
const capturedRequests = await runCustomCodexProviderTurn(
|
||||||
"codex-iisb",
|
"codex-iisb",
|
||||||
@@ -557,7 +610,7 @@ describe("Codex app-server provider", () => {
|
|||||||
},
|
},
|
||||||
"thread/resume": () => {
|
"thread/resume": () => {
|
||||||
threadRequests.push("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": () => {
|
"thread/start": () => {
|
||||||
threadRequests.push("thread/start");
|
threadRequests.push("thread/start");
|
||||||
@@ -604,7 +657,7 @@ describe("Codex app-server provider", () => {
|
|||||||
(error) => {
|
(error) => {
|
||||||
expect(error).toBeInstanceOf(Error);
|
expect(error).toBeInstanceOf(Error);
|
||||||
expect((error as Error).message).toContain(
|
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;
|
return "rejected" as const;
|
||||||
},
|
},
|
||||||
@@ -637,9 +690,7 @@ describe("Codex app-server provider", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect(
|
await expect(listCodexSkills(cwd, workspaceGitService)).resolves.toContainEqual({
|
||||||
__codexAppServerInternals.listCodexSkills(cwd, workspaceGitService),
|
|
||||||
).resolves.toContainEqual({
|
|
||||||
name: "shipper",
|
name: "shipper",
|
||||||
description: "Ship changes carefully.",
|
description: "Ship changes carefully.",
|
||||||
argumentHint: "",
|
argumentHint: "",
|
||||||
@@ -654,7 +705,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("extracts context window usage from snake_case token payloads", () => {
|
test("extracts context window usage from snake_case token payloads", () => {
|
||||||
expect(
|
expect(
|
||||||
__codexAppServerInternals.toAgentUsage({
|
toAgentUsage({
|
||||||
model_context_window: 200000,
|
model_context_window: 200000,
|
||||||
last: {
|
last: {
|
||||||
total_tokens: 50000,
|
total_tokens: 50000,
|
||||||
@@ -674,7 +725,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("extracts context window usage from camelCase token payloads", () => {
|
test("extracts context window usage from camelCase token payloads", () => {
|
||||||
expect(
|
expect(
|
||||||
__codexAppServerInternals.toAgentUsage({
|
toAgentUsage({
|
||||||
modelContextWindow: 200000,
|
modelContextWindow: 200000,
|
||||||
last: {
|
last: {
|
||||||
totalTokens: 50000,
|
totalTokens: 50000,
|
||||||
@@ -694,7 +745,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("keeps existing usage behavior when context window fields are missing", () => {
|
test("keeps existing usage behavior when context window fields are missing", () => {
|
||||||
expect(
|
expect(
|
||||||
__codexAppServerInternals.toAgentUsage({
|
toAgentUsage({
|
||||||
last: {
|
last: {
|
||||||
inputTokens: 30000,
|
inputTokens: 30000,
|
||||||
cachedInputTokens: 5000,
|
cachedInputTokens: 5000,
|
||||||
@@ -710,7 +761,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("excludes invalid context window values", () => {
|
test("excludes invalid context window values", () => {
|
||||||
expect(
|
expect(
|
||||||
__codexAppServerInternals.toAgentUsage({
|
toAgentUsage({
|
||||||
model_context_window: Number.NaN,
|
model_context_window: Number.NaN,
|
||||||
modelContextWindow: "200000",
|
modelContextWindow: "200000",
|
||||||
last: {
|
last: {
|
||||||
@@ -748,7 +799,7 @@ describe("Codex app-server provider", () => {
|
|||||||
required: ["overall"],
|
required: ["overall"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalized = __codexAppServerInternals.normalizeCodexOutputSchema(input);
|
const normalized = normalizeCodexOutputSchema(input);
|
||||||
|
|
||||||
expect(normalized).toEqual({
|
expect(normalized).toEqual({
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -1013,7 +1064,7 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("maps patch notifications with array-style changes and alias diff keys", () => {
|
test("maps patch notifications with array-style changes and alias diff keys", () => {
|
||||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
const item = mapCodexPatchNotificationToToolCall({
|
||||||
callId: "patch-array-alias",
|
callId: "patch-array-alias",
|
||||||
changes: [
|
changes: [
|
||||||
{
|
{
|
||||||
@@ -1036,7 +1087,7 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("maps Codex plan markdown to a synthetic plan tool call", () => {
|
test("maps Codex plan markdown to a synthetic plan tool call", () => {
|
||||||
const item = __codexAppServerInternals.mapCodexPlanToToolCall({
|
const item = mapCodexPlanToToolCall({
|
||||||
callId: "plan-turn-1",
|
callId: "plan-turn-1",
|
||||||
text: "### Login Screen\n- Build layout\n- Add validation",
|
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", () => {
|
test("maps patch notifications with object-style single change payloads", () => {
|
||||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
const item = mapCodexPatchNotificationToToolCall({
|
||||||
callId: "patch-object-single",
|
callId: "patch-object-single",
|
||||||
changes: {
|
changes: {
|
||||||
path: "/tmp/repo/src/object-single.ts",
|
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", () => {
|
test("maps patch notifications with file_path aliases in array-style changes", () => {
|
||||||
const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({
|
const item = mapCodexPatchNotificationToToolCall({
|
||||||
callId: "patch-array-file-path",
|
callId: "patch-array-file-path",
|
||||||
changes: [
|
changes: [
|
||||||
{
|
{
|
||||||
@@ -1105,7 +1156,7 @@ describe("Codex app-server provider", () => {
|
|||||||
PASEO_TEST_FLAG: "codex-launch-value",
|
PASEO_TEST_FLAG: "codex-launch-value",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const env = __codexAppServerInternals.buildCodexAppServerEnv(
|
const env = buildCodexAppServerEnv(
|
||||||
{
|
{
|
||||||
env: {
|
env: {
|
||||||
PASEO_AGENT_ID: "runtime-value",
|
PASEO_AGENT_ID: "runtime-value",
|
||||||
@@ -1499,6 +1550,7 @@ describe("Codex app-server provider", () => {
|
|||||||
item: {
|
item: {
|
||||||
type: "user_message",
|
type: "user_message",
|
||||||
text: "Check OpenCode timestamps.",
|
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", () => {
|
test("emits Codex context compaction markers from live thread items", () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
@@ -1694,7 +1780,7 @@ describe("Codex app-server provider", () => {
|
|||||||
return { data: [] };
|
return { data: [] };
|
||||||
}
|
}
|
||||||
if (method === "thread/resume") {
|
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") {
|
if (method === "thread/start") {
|
||||||
return { thread: { id: "replacement-empty-thread-id" } };
|
return { thread: { id: "replacement-empty-thread-id" } };
|
||||||
@@ -1704,7 +1790,7 @@ describe("Codex app-server provider", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
|
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");
|
expect(session.currentThreadId).toBe("archived-thread-id");
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
|||||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||||
import { curateAgentActivity } from "../activity-curator.js";
|
import { curateAgentActivity } from "../activity-curator.js";
|
||||||
import {
|
import {
|
||||||
mapCodexRolloutToolCall,
|
mapCodexToolCallEnvelope,
|
||||||
mapCodexToolCallFromThreadItem,
|
mapCodexToolCallFromThreadItem,
|
||||||
} from "./codex/tool-call-mapper.js";
|
} from "./codex/tool-call-mapper.js";
|
||||||
import {
|
import {
|
||||||
@@ -60,8 +60,15 @@ import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mappe
|
|||||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||||
import {
|
import {
|
||||||
CodexAppServerClient,
|
CodexAppServerClient,
|
||||||
|
parseCodexThreadForkResponse,
|
||||||
|
parseCodexThreadRollbackResponse,
|
||||||
|
type CodexThreadForkParams,
|
||||||
|
type CodexThreadForkResponse,
|
||||||
|
type CodexThreadRollbackParams,
|
||||||
|
type CodexThreadRollbackResponse,
|
||||||
type CodexAppServerTraceContext,
|
type CodexAppServerTraceContext,
|
||||||
} from "./codex/app-server-transport.js";
|
} from "./codex/app-server-transport.js";
|
||||||
|
import { type CodexUserMessageTurnIndex, revertCodexConversation } from "./codex/rewind.js";
|
||||||
import {
|
import {
|
||||||
renderProviderImageOutputAsAssistantMarkdown,
|
renderProviderImageOutputAsAssistantMarkdown,
|
||||||
type ProviderImageOutput,
|
type ProviderImageOutput,
|
||||||
@@ -158,6 +165,9 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: true,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const CODEX_MODES: AgentMode[] = [
|
const CODEX_MODES: AgentMode[] = [
|
||||||
@@ -183,6 +193,8 @@ const DEFAULT_CODEX_MODE_ID = "auto";
|
|||||||
|
|
||||||
interface CodexAppServerClientLike {
|
interface CodexAppServerClientLike {
|
||||||
request(method: string, params?: unknown): Promise<unknown>;
|
request(method: string, params?: unknown): Promise<unknown>;
|
||||||
|
forkThread?(params: CodexThreadForkParams): Promise<CodexThreadForkResponse>;
|
||||||
|
rollbackThread?(params: CodexThreadRollbackParams): Promise<CodexThreadRollbackResponse>;
|
||||||
notify(method: string, params?: unknown): void;
|
notify(method: string, params?: unknown): void;
|
||||||
dispose(): Promise<void>;
|
dispose(): Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -343,7 +355,7 @@ function normalizeCodexOutputSchemaNode(schema: unknown, schemaPath: string): un
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
|
export function normalizeCodexOutputSchema(schema: unknown): Record<string, unknown> {
|
||||||
if (!isSchemaRecord(schema)) {
|
if (!isSchemaRecord(schema)) {
|
||||||
throw new Error("Codex structured outputs require a JSON object 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");
|
return path.join(localAppData, "Packages");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
export async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -425,7 +437,7 @@ async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findDefaultCodexBinary(): Promise<string | null> {
|
export async function findDefaultCodexBinary(): Promise<string | null> {
|
||||||
return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary());
|
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));
|
return commands.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listCodexSkills(
|
export async function listCodexSkills(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">,
|
||||||
): Promise<AgentSlashCommand[]> {
|
): Promise<AgentSlashCommand[]> {
|
||||||
@@ -796,7 +808,7 @@ function filterCodexThreadsByCwd(
|
|||||||
return threads.filter((thread) => typeof thread.cwd === "string" && matchesCwd(thread.cwd));
|
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);
|
const usage = toObjectRecord(tokenUsage);
|
||||||
if (!usage) return undefined;
|
if (!usage) return undefined;
|
||||||
const last = toObjectRecord(usage.last);
|
const last = toObjectRecord(usage.last);
|
||||||
@@ -838,7 +850,7 @@ function normalizePlanMarkdown(text: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
|
export function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string {
|
||||||
const lines = steps
|
const lines = steps
|
||||||
.map((entry) => entry.step.trim())
|
.map((entry) => entry.step.trim())
|
||||||
.filter((step) => step.length > 0)
|
.filter((step) => step.length > 0)
|
||||||
@@ -851,7 +863,7 @@ function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): st
|
|||||||
return normalizePlanMarkdown(lines.join("\n"));
|
return normalizePlanMarkdown(lines.join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCodexPlanToToolCall(params: {
|
export function mapCodexPlanToToolCall(params: {
|
||||||
callId: string;
|
callId: string;
|
||||||
text: string;
|
text: string;
|
||||||
}): ToolCallTimelineItem | null {
|
}): ToolCallTimelineItem | null {
|
||||||
@@ -935,7 +947,7 @@ interface CodexQuestionPrompt {
|
|||||||
isSecret?: boolean;
|
isSecret?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
|
export function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -985,7 +997,7 @@ function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] {
|
|||||||
return questions;
|
return questions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
|
export function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
|
||||||
return questions
|
return questions
|
||||||
.map((question) => {
|
.map((question) => {
|
||||||
const lines = [`${question.header}: ${question.question}`];
|
const lines = [`${question.header}: ${question.question}`];
|
||||||
@@ -998,7 +1010,7 @@ function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCodexQuestionRequestToToolCall(params: {
|
export function mapCodexQuestionRequestToToolCall(params: {
|
||||||
callId: string;
|
callId: string;
|
||||||
questions: CodexQuestionPrompt[];
|
questions: CodexQuestionPrompt[];
|
||||||
status: ToolCallTimelineItem["status"];
|
status: ToolCallTimelineItem["status"];
|
||||||
@@ -1360,7 +1372,7 @@ function mapCodexExecNotificationToToolCall(params: {
|
|||||||
? { exitCode: params.exitCode }
|
? { exitCode: params.exitCode }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
const mapped = mapCodexRolloutToolCall({
|
const mapped = mapCodexToolCallEnvelope({
|
||||||
callId: params.callId ?? null,
|
callId: params.callId ?? null,
|
||||||
name: "shell",
|
name: "shell",
|
||||||
input: {
|
input: {
|
||||||
@@ -1377,7 +1389,7 @@ function mapCodexExecNotificationToToolCall(params: {
|
|||||||
return params.running ? toRunningToolCall(mapped) : mapped;
|
return params.running ? toRunningToolCall(mapped) : mapped;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCodexPatchNotificationToToolCall(params: {
|
export function mapCodexPatchNotificationToToolCall(params: {
|
||||||
callId?: string | null;
|
callId?: string | null;
|
||||||
changes: unknown;
|
changes: unknown;
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
@@ -1393,7 +1405,7 @@ function mapCodexPatchNotificationToToolCall(params: {
|
|||||||
.find((value): value is string => typeof value === "string" && value.length > 0);
|
.find((value): value is string => typeof value === "string" && value.length > 0);
|
||||||
const patchText = firstPatchText;
|
const patchText = firstPatchText;
|
||||||
const patchFields = codexPatchTextFields(patchText);
|
const patchFields = codexPatchTextFields(patchText);
|
||||||
const mapped = mapCodexRolloutToolCall({
|
const mapped = mapCodexToolCallEnvelope({
|
||||||
callId: params.callId ?? null,
|
callId: params.callId ?? null,
|
||||||
name: "apply_patch",
|
name: "apply_patch",
|
||||||
input: firstPath
|
input: firstPath
|
||||||
@@ -1490,7 +1502,12 @@ function mapCodexThreadUserMessageItem(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const text = extractUserText(normalizedItem.content) ?? "";
|
const text = extractUserText(normalizedItem.content) ?? "";
|
||||||
return { type: "user_message", text };
|
const messageId = nonEmptyString(normalizedItem.id);
|
||||||
|
return {
|
||||||
|
type: "user_message",
|
||||||
|
text,
|
||||||
|
...(messageId ? { messageId } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstStringField(
|
function firstStringField(
|
||||||
@@ -1605,7 +1622,7 @@ function mapCodexThreadImageItem(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function threadItemToTimeline(
|
export function threadItemToTimeline(
|
||||||
item: unknown,
|
item: unknown,
|
||||||
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
||||||
): AgentTimelineItem | 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> {
|
function toSandboxPolicy(type: string, networkAccess?: boolean): Record<string, unknown> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "read-only":
|
case "read-only":
|
||||||
@@ -1999,6 +2036,18 @@ const CodexEventTurnDiffNotificationSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.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 =
|
type ParsedCodexNotification =
|
||||||
| { kind: "thread_started"; threadId: string }
|
| { kind: "thread_started"; threadId: string }
|
||||||
| { kind: "turn_started"; turnId: string; threadId: string | null }
|
| { kind: "turn_started"; turnId: string; threadId: string | null }
|
||||||
@@ -2072,10 +2121,33 @@ type ParsedCodexNotification =
|
|||||||
itemId: string;
|
itemId: string;
|
||||||
delta: string | null;
|
delta: string | null;
|
||||||
}
|
}
|
||||||
|
| { kind: "thread_rolled_back"; numTurns: number }
|
||||||
| { kind: "context_compacted"; threadId: string; turnId: string | null }
|
| { kind: "context_compacted"; threadId: string; turnId: string | null }
|
||||||
| { kind: "invalid_payload"; method: string; params: unknown }
|
| { kind: "invalid_payload"; method: string; params: unknown }
|
||||||
| { kind: "unknown_method"; 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([
|
const CodexNotificationSchema = z.union([
|
||||||
z
|
z
|
||||||
.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema })
|
.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema })
|
||||||
@@ -2548,6 +2620,24 @@ const CodexNotificationSchema = z.union([
|
|||||||
params,
|
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
|
z
|
||||||
.object({ method: z.string(), params: z.unknown() })
|
.object({ method: z.string(), params: z.unknown() })
|
||||||
.transform(
|
.transform(
|
||||||
@@ -2685,7 +2775,7 @@ function toCodexTextInput(text: string): Extract<CodexAppServerUserInput, { type
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCodexAppServerEnv(
|
export function buildCodexAppServerEnv(
|
||||||
runtimeSettings?: ProviderRuntimeSettings,
|
runtimeSettings?: ProviderRuntimeSettings,
|
||||||
launchEnv?: Record<string, string>,
|
launchEnv?: Record<string, string>,
|
||||||
): NodeJS.ProcessEnv {
|
): NodeJS.ProcessEnv {
|
||||||
@@ -2762,7 +2852,7 @@ interface CodexSubAgentCallState {
|
|||||||
childItems: Map<string, AgentTimelineItem>;
|
childItems: Map<string, AgentTimelineItem>;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CodexAppServerAgentSession implements AgentSession {
|
export class CodexAppServerAgentSession implements AgentSession {
|
||||||
readonly provider = CODEX_PROVIDER;
|
readonly provider = CODEX_PROVIDER;
|
||||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||||
|
|
||||||
@@ -2810,6 +2900,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private warnedIncompleteEditToolCallIds = new Set<string>();
|
private warnedIncompleteEditToolCallIds = new Set<string>();
|
||||||
private latestUsage: AgentUsage | undefined;
|
private latestUsage: AgentUsage | undefined;
|
||||||
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
|
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 pendingManualCompactionStarts = 0;
|
||||||
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
|
||||||
// Codex can report one completed compaction through both channels:
|
// Codex can report one completed compaction through both channels:
|
||||||
@@ -3135,20 +3227,22 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
const client = this.client;
|
const client = this.client;
|
||||||
const threadId = this.currentThreadId;
|
const threadId = this.currentThreadId;
|
||||||
|
|
||||||
try {
|
const timeline = await loadCodexThreadHistoryTimeline({
|
||||||
const timeline = await loadCodexThreadHistoryTimeline({
|
threadId,
|
||||||
threadId,
|
cwd: this.config.cwd ?? null,
|
||||||
cwd: this.config.cwd ?? null,
|
requestThread: (threadIdToRead) => {
|
||||||
requestThread: (threadIdToRead) => {
|
return readCodexThread(client, threadIdToRead);
|
||||||
return readCodexThread(client, threadIdToRead);
|
},
|
||||||
},
|
});
|
||||||
});
|
this.resetCodexUserMessageTurns();
|
||||||
if (timeline.length > 0) {
|
for (const entry of timeline) {
|
||||||
this.persistedHistory = timeline;
|
if (entry.item.type === "user_message") {
|
||||||
this.historyPending = true;
|
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 };
|
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 {
|
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||||
this.subscribers.add(callback);
|
this.subscribers.add(callback);
|
||||||
return () => {
|
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> {
|
async interrupt(): Promise<void> {
|
||||||
if (!this.client || !this.currentThreadId || !this.currentTurnId) return;
|
if (!this.client || !this.currentThreadId || !this.currentTurnId) return;
|
||||||
try {
|
try {
|
||||||
@@ -4059,6 +4217,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private handleNotification(method: string, params: unknown): void {
|
private handleNotification(method: string, params: unknown): void {
|
||||||
const parsed = CodexNotificationSchema.parse({ method, params });
|
const parsed = CodexNotificationSchema.parse({ method, params });
|
||||||
this.traceParsedNotification(method, params, parsed);
|
this.traceParsedNotification(method, params, parsed);
|
||||||
|
if (isCodexDeltaNotification(parsed)) {
|
||||||
|
this.handleCodexDeltaNotification(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.handleThreadStateNotification(parsed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (parsed.kind) {
|
switch (parsed.kind) {
|
||||||
case "thread_started":
|
case "thread_started":
|
||||||
this.handleThreadStartedNotification(parsed);
|
this.handleThreadStartedNotification(parsed);
|
||||||
@@ -4080,15 +4245,6 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
case "token_usage_updated":
|
case "token_usage_updated":
|
||||||
this.handleTokenUsageUpdatedNotification(parsed);
|
this.handleTokenUsageUpdatedNotification(parsed);
|
||||||
return;
|
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":
|
case "exec_command_started":
|
||||||
this.handleExecCommandStartedNotification(parsed);
|
this.handleExecCommandStartedNotification(parsed);
|
||||||
return;
|
return;
|
||||||
@@ -4113,8 +4269,24 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
case "invalid_payload":
|
case "invalid_payload":
|
||||||
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
|
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
|
||||||
return;
|
return;
|
||||||
default:
|
case "unknown_method":
|
||||||
this.warnUnknownNotificationMethod(parsed.method, parsed.params);
|
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));
|
return Boolean(itemId && this.emittedItemCompletedIds.has(itemId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleCodexDeltaNotification(
|
private handleCodexDeltaNotification(parsed: CodexDeltaNotification): void {
|
||||||
parsed: Extract<
|
|
||||||
ParsedCodexNotification,
|
|
||||||
{
|
|
||||||
kind:
|
|
||||||
| "agent_message_delta"
|
|
||||||
| "reasoning_delta"
|
|
||||||
| "exec_command_output_delta"
|
|
||||||
| "file_change_output_delta";
|
|
||||||
}
|
|
||||||
>,
|
|
||||||
): void {
|
|
||||||
if (parsed.kind === "agent_message_delta") {
|
if (parsed.kind === "agent_message_delta") {
|
||||||
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
|
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
|
||||||
const text = prev + parsed.delta;
|
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(
|
private handleContextCompactedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "context_compacted" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "context_compacted" }>,
|
||||||
): void {
|
): void {
|
||||||
@@ -4626,6 +4800,10 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (parsed.source === "codex_event") {
|
if (parsed.source === "codex_event") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.isUserMessageItem(parsed.item)) {
|
||||||
|
this.handleUserMessageItem(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.isContextCompactionItem(parsed.item)) {
|
if (this.isContextCompactionItem(parsed.item)) {
|
||||||
if (this.unpairedCompactionNotificationCompletions > 0) {
|
if (this.unpairedCompactionNotificationCompletions > 0) {
|
||||||
this.unpairedCompactionNotificationCompletions -= 1;
|
this.unpairedCompactionNotificationCompletions -= 1;
|
||||||
@@ -4769,6 +4947,10 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (parsed.source === "codex_event") {
|
if (parsed.source === "codex_event") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.isUserMessageItem(parsed.item)) {
|
||||||
|
this.handleUserMessageItem(parsed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.isContextCompactionItem(parsed.item)) {
|
if (this.isContextCompactionItem(parsed.item)) {
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "timeline",
|
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 {
|
private warnUnknownNotificationMethod(method: string, params: unknown): void {
|
||||||
if (this.warnedUnknownNotificationMethods.has(method)) {
|
if (this.warnedUnknownNotificationMethods.has(method)) {
|
||||||
return;
|
return;
|
||||||
@@ -5510,22 +5717,3 @@ function resolveSkillDescription(skill: Record<string, unknown>): string {
|
|||||||
}
|
}
|
||||||
return "Skill";
|
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 { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
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";
|
import { CodexAppServerClient } from "./app-server-transport.js";
|
||||||
|
|
||||||
describe("Codex app-server transport", () => {
|
describe("Codex app-server transport", () => {
|
||||||
@@ -25,23 +28,87 @@ describe("Codex app-server transport", () => {
|
|||||||
"item/tool/requestUserInput",
|
"item/tool/requestUserInput",
|
||||||
"tool/requestUserInput",
|
"tool/requestUserInput",
|
||||||
])("answers server-initiated %s requests through registered handlers", async (method) => {
|
])("answers server-initiated %s requests through registered handlers", async (method) => {
|
||||||
const child = createCodexAppServerChildProcess();
|
const codex = createFakeCodexAppServer();
|
||||||
const client = new CodexAppServerClient(child, createTestLogger());
|
const client = new CodexAppServerClient(codex.child, createTestLogger());
|
||||||
const handlerCalls: unknown[] = [];
|
const handlerCalls: unknown[] = [];
|
||||||
client.setRequestHandler(method, async (params) => {
|
client.setRequestHandler(method, async (params) => {
|
||||||
handlerCalls.push(params);
|
handlerCalls.push(params);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = new Promise<string>((resolve) => {
|
const response = codex.nextResponse();
|
||||||
child.stdin.once("data", (chunk) => resolve(chunk.toString()));
|
codex.child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
|
||||||
});
|
|
||||||
child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 7, method, params: {} })}\n`);
|
|
||||||
|
|
||||||
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
|
await expect(response).resolves.toBe('{"id":7,"result":{"ok":true}}\n');
|
||||||
expect(handlerCalls).toEqual([{}]);
|
expect(handlerCalls).toEqual([{}]);
|
||||||
child.stdout.end();
|
codex.child.stdout.end();
|
||||||
child.stderr.end();
|
codex.child.stderr.end();
|
||||||
child.stdin.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 type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import readline from "node:readline";
|
import readline from "node:readline";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
||||||
|
|
||||||
@@ -35,6 +36,81 @@ interface PendingRequest {
|
|||||||
type RequestHandler = (params: unknown) => unknown;
|
type RequestHandler = (params: unknown) => unknown;
|
||||||
type NotificationHandler = (method: string, params: unknown) => void;
|
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 {
|
export interface CodexAppServerTraceContext {
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
sessionId?: 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 {
|
notify(method: string, params?: unknown): void {
|
||||||
if (this.disposed) {
|
if (this.disposed) {
|
||||||
return;
|
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 {
|
export interface FakeCodexAppServer {
|
||||||
readonly child: CodexAppServerChildProcess;
|
readonly child: CodexAppServerChildProcess;
|
||||||
|
readonly recordedRollbacks: JsonObject[];
|
||||||
assertNoErrors(): void;
|
assertNoErrors(): void;
|
||||||
waitForTurnStart(): Promise<JsonObject>;
|
waitForTurnStart(): Promise<JsonObject>;
|
||||||
|
nextResponse(): Promise<string>;
|
||||||
|
completeTurn(params?: { threadId?: string }): void;
|
||||||
requestCommandApproval(params: {
|
requestCommandApproval(params: {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
@@ -46,6 +49,7 @@ export function createFakeCodexAppServer(
|
|||||||
handlers: Record<string, FakeCodexAppServerHandler> = {},
|
handlers: Record<string, FakeCodexAppServerHandler> = {},
|
||||||
): FakeCodexAppServer {
|
): FakeCodexAppServer {
|
||||||
const child = createCodexAppServerChildProcess();
|
const child = createCodexAppServerChildProcess();
|
||||||
|
const recordedRollbacks: JsonObject[] = [];
|
||||||
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
|
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
|
||||||
initialize: () => ({}),
|
initialize: () => ({}),
|
||||||
"collaborationMode/list": () => ({ data: [] }),
|
"collaborationMode/list": () => ({ data: [] }),
|
||||||
@@ -65,6 +69,38 @@ export function createFakeCodexAppServer(
|
|||||||
"thread/loaded/list": () => ({ data: [] }),
|
"thread/loaded/list": () => ({ data: [] }),
|
||||||
"thread/resume": () => ({}),
|
"thread/resume": () => ({}),
|
||||||
"turn/start": () => ({}),
|
"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,
|
...handlers,
|
||||||
};
|
};
|
||||||
const messages: JsonObject[] = [];
|
const messages: JsonObject[] = [];
|
||||||
@@ -161,6 +197,7 @@ export function createFakeCodexAppServer(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
child,
|
child,
|
||||||
|
recordedRollbacks,
|
||||||
assertNoErrors() {
|
assertNoErrors() {
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
throw errors[0];
|
throw errors[0];
|
||||||
@@ -173,6 +210,19 @@ export function createFakeCodexAppServer(
|
|||||||
);
|
);
|
||||||
return toJsonObject(message.params);
|
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) {
|
requestCommandApproval(params) {
|
||||||
const requestId = nextServerRequestId;
|
const requestId = nextServerRequestId;
|
||||||
nextServerRequestId += 1;
|
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"]);
|
const CodexToolCallStatusSchema = z.enum(["running", "completed", "failed", "canceled"]);
|
||||||
type CodexToolCallStatus = z.infer<typeof CodexToolCallStatusSchema>;
|
type CodexToolCallStatus = z.infer<typeof CodexToolCallStatusSchema>;
|
||||||
|
|
||||||
const CodexRolloutToolCallParamsSchema = z
|
const CodexToolCallEnvelopeParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
callId: z.string().optional().nullable(),
|
callId: z.string().optional().nullable(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -432,7 +432,7 @@ function asEditTextFields(text: string | undefined): { unifiedDiff?: string; new
|
|||||||
return { newString: text };
|
return { newString: text };
|
||||||
}
|
}
|
||||||
|
|
||||||
function findRolloutEditPatchText(input: Record<string, unknown>): string | undefined {
|
function findToolCallEditPatchText(input: Record<string, unknown>): string | undefined {
|
||||||
return (
|
return (
|
||||||
(typeof input.patch === "string" && input.patch) ||
|
(typeof input.patch === "string" && input.patch) ||
|
||||||
(typeof input.diff === "string" && input.diff) ||
|
(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>,
|
input: Record<string, unknown>,
|
||||||
patchText: string,
|
patchText: string,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
@@ -459,14 +459,14 @@ function findRolloutEditInputPath(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRolloutEditRecordInput(input: Record<string, unknown>): unknown {
|
function normalizeToolCallEditRecordInput(input: Record<string, unknown>): unknown {
|
||||||
const candidatePatchText = findRolloutEditPatchText(input);
|
const candidatePatchText = findToolCallEditPatchText(input);
|
||||||
if (!candidatePatchText) {
|
if (!candidatePatchText) {
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textFields = asEditTextFields(candidatePatchText);
|
const textFields = asEditTextFields(candidatePatchText);
|
||||||
const rawPath = findRolloutEditInputPath(input, candidatePatchText);
|
const rawPath = findToolCallEditInputPath(input, candidatePatchText);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
patch: _patch,
|
patch: _patch,
|
||||||
@@ -490,7 +490,7 @@ function normalizeRolloutEditRecordInput(input: Record<string, unknown>): unknow
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeRolloutEditInput(input: unknown): unknown {
|
function normalizeToolCallEditInput(input: unknown): unknown {
|
||||||
if (typeof input === "string") {
|
if (typeof input === "string") {
|
||||||
const textFields = asEditTextFields(input);
|
const textFields = asEditTextFields(input);
|
||||||
const path = extractPatchPrimaryFilePath(input);
|
const path = extractPatchPrimaryFilePath(input);
|
||||||
@@ -503,7 +503,7 @@ function normalizeRolloutEditInput(input: unknown): unknown {
|
|||||||
if (!isRecord(input)) {
|
if (!isRecord(input)) {
|
||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
return normalizeRolloutEditRecordInput(input);
|
return normalizeToolCallEditRecordInput(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
function asEditFileOutputFields(text: string | undefined): { patch?: string; content?: string } {
|
function asEditFileOutputFields(text: string | undefined): { patch?: string; content?: string } {
|
||||||
@@ -965,7 +965,7 @@ export function mapCodexToolCallFromThreadItem(
|
|||||||
return toToolCallFromNormalizedEnvelope(envelope);
|
return toToolCallFromNormalizedEnvelope(envelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapCodexRolloutToolCall(params: {
|
export function mapCodexToolCallEnvelope(params: {
|
||||||
callId?: string | null;
|
callId?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
input?: unknown;
|
input?: unknown;
|
||||||
@@ -973,7 +973,7 @@ export function mapCodexRolloutToolCall(params: {
|
|||||||
error?: unknown;
|
error?: unknown;
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
}): ToolCallTimelineItem | null {
|
}): ToolCallTimelineItem | null {
|
||||||
const parsed = CodexRolloutToolCallParamsSchema.safeParse(params);
|
const parsed = CodexToolCallEnvelopeParamsSchema.safeParse(params);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -988,7 +988,7 @@ export function mapCodexRolloutToolCall(params: {
|
|||||||
}
|
}
|
||||||
const normalizedInput =
|
const normalizedInput =
|
||||||
normalizedName === "apply_patch" || normalizedName === "apply_diff"
|
normalizedName === "apply_patch" || normalizedName === "apply_diff"
|
||||||
? normalizeRolloutEditInput(parsed.data.input ?? null)
|
? normalizeToolCallEditInput(parsed.data.input ?? null)
|
||||||
: (parsed.data.input ?? null);
|
: (parsed.data.input ?? null);
|
||||||
|
|
||||||
return toToolCallFromNormalizedEnvelope({
|
return toToolCallFromNormalizedEnvelope({
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: false,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
|
const COPILOT_AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ const CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: true,
|
||||||
|
supportsRewindFiles: true,
|
||||||
|
supportsRewindBoth: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MODELS: AgentModelDefinition[] = [
|
const MODELS: AgentModelDefinition[] = [
|
||||||
@@ -449,12 +452,17 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||||
private modeId: string | null;
|
private modeId: string | null;
|
||||||
private modelId: string | null;
|
private modelId: string | null;
|
||||||
|
private readonly rewindError: string | null;
|
||||||
|
|
||||||
constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) {
|
constructor(options: { config: AgentSessionConfig; sessionId: string; logger?: Logger }) {
|
||||||
this.id = options.sessionId;
|
this.id = options.sessionId;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
|
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
|
||||||
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_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> {
|
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||||
@@ -495,6 +503,22 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
turnStarted: false,
|
turnStarted: false,
|
||||||
};
|
};
|
||||||
this.activeTurn = turn;
|
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 largePayload = parseLargeAgentStreamPayloadPrompt(prompt);
|
||||||
const stress = parseAgentStreamStressPrompt(prompt);
|
const stress = parseAgentStreamStressPrompt(prompt);
|
||||||
@@ -609,6 +633,21 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
this.listeners.clear();
|
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> {
|
async setModel(modelId: string | null): Promise<void> {
|
||||||
this.modelId = modelId ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
|
this.modelId = modelId ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
|
||||||
}
|
}
|
||||||
@@ -620,6 +659,12 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
turn.timer.unref?.();
|
turn.timer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private failConfiguredRewind(): void {
|
||||||
|
if (this.rewindError) {
|
||||||
|
throw new Error(this.rewindError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private scheduleLargePayloadTurn(
|
private scheduleLargePayloadTurn(
|
||||||
turn: ActiveTurn,
|
turn: ActiveTurn,
|
||||||
largePayload: LargeAgentStreamPayloadRequest,
|
largePayload: LargeAgentStreamPayloadRequest,
|
||||||
@@ -941,7 +986,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emit(event: AgentStreamEvent): void {
|
private emit(event: AgentStreamEvent): void {
|
||||||
this.history.push(event);
|
this.remember(event);
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
try {
|
try {
|
||||||
listener(event);
|
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 {
|
private clearTurnTimer(turn: ActiveTurn): void {
|
||||||
if (!turn.timer) {
|
if (!turn.timer) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -937,6 +937,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
|||||||
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
|
test("streamHistory preserves OpenCode replay timestamps from message and part times", async () => {
|
||||||
const fakeClient = {
|
const fakeClient = {
|
||||||
session: {
|
session: {
|
||||||
|
get: vi.fn().mockResolvedValue({
|
||||||
|
data: { revert: undefined },
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
messages: vi.fn().mockResolvedValue({
|
messages: vi.fn().mockResolvedValue({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
@@ -1029,6 +1033,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
|||||||
test("streamHistory omits replay timestamps when OpenCode omits times", async () => {
|
test("streamHistory omits replay timestamps when OpenCode omits times", async () => {
|
||||||
const fakeClient = {
|
const fakeClient = {
|
||||||
session: {
|
session: {
|
||||||
|
get: vi.fn().mockResolvedValue({
|
||||||
|
data: { revert: undefined },
|
||||||
|
error: undefined,
|
||||||
|
}),
|
||||||
messages: vi.fn().mockResolvedValue({
|
messages: vi.fn().mockResolvedValue({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ import {
|
|||||||
type OpenCodeServerAcquisition,
|
type OpenCodeServerAcquisition,
|
||||||
} from "./opencode/runtime.js";
|
} from "./opencode/runtime.js";
|
||||||
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js";
|
||||||
|
import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js";
|
||||||
|
|
||||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
@@ -75,6 +76,9 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: true,
|
supportsMcpServers: true,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: false,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPENCODE_BUILD_MODE_ID = "build";
|
const OPENCODE_BUILD_MODE_ID = "build";
|
||||||
@@ -700,6 +704,24 @@ function buildOpenCodePromptParts(
|
|||||||
return output;
|
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(
|
async function collectOpenCodePersistedAgentsFromSdk(
|
||||||
client: Pick<OpencodeClient, "experimental" | "session">,
|
client: Pick<OpencodeClient, "experimental" | "session">,
|
||||||
options?: ListPersistedAgentsOptions,
|
options?: ListPersistedAgentsOptions,
|
||||||
@@ -845,7 +867,7 @@ async function readOpenCodeSessionMessagesFromSdk(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data;
|
return filterOpenCodeRevertedMessages(response.data, session.revert);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOpenCodeSessionTimeline(
|
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(
|
function resolveOpenCodePersistedSessionModeId(
|
||||||
session: OpenCodePersistedSession,
|
session: OpenCodePersistedSession,
|
||||||
messages: ReadonlyArray<OpenCodeSessionMessage>,
|
messages: ReadonlyArray<OpenCodeSessionMessage>,
|
||||||
@@ -1352,6 +1388,8 @@ export interface OpenCodeEventTranslationState {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
messageRoles: Map<string, OpenCodeMessageRole>;
|
messageRoles: Map<string, OpenCodeMessageRole>;
|
||||||
|
pendingUserMessageText?: string | null;
|
||||||
|
emittedUserMessageIds?: Set<string>;
|
||||||
accumulatedUsage: AgentUsage;
|
accumulatedUsage: AgentUsage;
|
||||||
sessionTotalCostUsd?: number;
|
sessionTotalCostUsd?: number;
|
||||||
streamedPartKeys: Set<string>;
|
streamedPartKeys: Set<string>;
|
||||||
@@ -1969,6 +2007,10 @@ function appendOpenCodeMessageUpdated(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.messageRoles.set(info.id, info.role);
|
state.messageRoles.set(info.id, info.role);
|
||||||
|
if (info.role === "user") {
|
||||||
|
appendOpenCodeUserMessageUpdated(info, state, events);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (info.role !== "assistant") {
|
if (info.role !== "assistant") {
|
||||||
return;
|
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(
|
function appendOpenCodeMessagePartUpdated(
|
||||||
event: Extract<OpenCodeEvent, { type: "message.part.updated" }>,
|
event: Extract<OpenCodeEvent, { type: "message.part.updated" }>,
|
||||||
state: OpenCodeEventTranslationState,
|
state: OpenCodeEventTranslationState,
|
||||||
@@ -2322,6 +2381,8 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
private mcpSetupPromise: Promise<void> | null = null;
|
private mcpSetupPromise: Promise<void> | null = null;
|
||||||
/** Tracks the role of each message by ID to distinguish user from assistant messages */
|
/** Tracks the role of each message by ID to distinguish user from assistant messages */
|
||||||
private messageRoles = new Map<string, OpenCodeMessageRole>();
|
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. */
|
/** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */
|
||||||
private streamedPartKeys = new Set<string>();
|
private streamedPartKeys = new Set<string>();
|
||||||
/** Tracks assistant messages already emitted from structured payloads. */
|
/** 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> {
|
private beginSessionAbort(turnId: string | null, reason: string): Promise<void> {
|
||||||
const abortPromise = this.client.session
|
const abortPromise = this.client.session
|
||||||
.abort({
|
.abort({
|
||||||
@@ -2492,6 +2562,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
this.accumulatedUsage = contextWindowMaxTokens !== undefined ? { contextWindowMaxTokens } : {};
|
||||||
|
|
||||||
const parts = buildOpenCodePromptParts(prompt);
|
const parts = buildOpenCodePromptParts(prompt);
|
||||||
|
this.pendingUserMessageText = buildOpenCodeUserTimelineText(prompt);
|
||||||
const model = this.parseModel(this.config.model);
|
const model = this.parseModel(this.config.model);
|
||||||
const thinkingOptionId = this.config.thinkingOptionId;
|
const thinkingOptionId = this.config.thinkingOptionId;
|
||||||
const effectiveVariant = thinkingOptionId ?? undefined;
|
const effectiveVariant = thinkingOptionId ?? undefined;
|
||||||
@@ -2856,6 +2927,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
} else {
|
} else {
|
||||||
this.runningToolCalls.clear();
|
this.runningToolCalls.clear();
|
||||||
}
|
}
|
||||||
|
this.pendingUserMessageText = null;
|
||||||
this.activeForegroundTurnId = null;
|
this.activeForegroundTurnId = null;
|
||||||
this.abortController = null;
|
this.abortController = null;
|
||||||
this.notifySubscribers(event, turnId);
|
this.notifySubscribers(event, turnId);
|
||||||
@@ -2934,6 +3006,10 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
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({
|
const response = await this.client.session.messages({
|
||||||
sessionID: this.sessionId,
|
sessionID: this.sessionId,
|
||||||
directory: this.config.cwd,
|
directory: this.config.cwd,
|
||||||
@@ -2943,7 +3019,11 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
return;
|
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)) {
|
for (const event of buildOpenCodeReplayTimelineEvents(message)) {
|
||||||
yield event;
|
yield event;
|
||||||
}
|
}
|
||||||
@@ -3215,6 +3295,8 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
sessionId: this.sessionId,
|
sessionId: this.sessionId,
|
||||||
cwd: this.config.cwd,
|
cwd: this.config.cwd,
|
||||||
messageRoles: this.messageRoles,
|
messageRoles: this.messageRoles,
|
||||||
|
pendingUserMessageText: this.pendingUserMessageText,
|
||||||
|
emittedUserMessageIds: this.emittedUserMessageIds,
|
||||||
accumulatedUsage: this.accumulatedUsage,
|
accumulatedUsage: this.accumulatedUsage,
|
||||||
sessionTotalCostUsd: this.sessionTotalCostUsd,
|
sessionTotalCostUsd: this.sessionTotalCostUsd,
|
||||||
streamedPartKeys: this.streamedPartKeys,
|
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 { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
@@ -15,12 +13,12 @@ function createClient(pi = new FakePi()): PiRpcAgentClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createClientWithPiAgentDir(agentDir: string): PiRpcAgentClient {
|
function rewindCapabilities(capabilities: PiRpcAgentSession["capabilities"]) {
|
||||||
return new PiRpcAgentClient({
|
return {
|
||||||
logger: pino({ level: "silent" }),
|
supportsRewindConversation: capabilities.supportsRewindConversation,
|
||||||
runtime: new FakePi(),
|
supportsRewindFiles: capabilities.supportsRewindFiles,
|
||||||
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
|
supportsRewindBoth: capabilities.supportsRewindBoth,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
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<{
|
async function createSession(pi = new FakePi()): Promise<{
|
||||||
pi: FakePi;
|
pi: FakePi;
|
||||||
session: PiRpcAgentSession;
|
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>(
|
private nextEvent<T extends AgentStreamEvent>(
|
||||||
predicate: (event: AgentStreamEvent) => event is T,
|
predicate: (event: AgentStreamEvent) => event is T,
|
||||||
): Promise<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 () => {
|
test("resumes by launching Pi with the persisted session file and cwd metadata", async () => {
|
||||||
const pi = new FakePi();
|
const pi = new FakePi();
|
||||||
const client = createClient(pi);
|
const client = createClient(pi);
|
||||||
@@ -314,7 +374,7 @@ describe("PiRpcAgentSession", () => {
|
|||||||
{
|
{
|
||||||
provider: "pi",
|
provider: "pi",
|
||||||
sessionId: "pi-session-1",
|
sessionId: "pi-session-1",
|
||||||
nativeHandle: "/tmp/native-pi-session.jsonl",
|
nativeHandle: "/tmp/native-pi-session",
|
||||||
metadata: {
|
metadata: {
|
||||||
cwd: "/workspace/project",
|
cwd: "/workspace/project",
|
||||||
model: "openrouter/model-a",
|
model: "openrouter/model-a",
|
||||||
@@ -324,22 +384,25 @@ describe("PiRpcAgentSession", () => {
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(pi.recordedLaunches).toEqual([
|
expect(pi.recordedLaunches).toHaveLength(1);
|
||||||
expect.objectContaining({
|
const actualLaunch = pi.recordedLaunches[0]!;
|
||||||
cwd: "/workspace/project",
|
expect(actualLaunch).toMatchObject({
|
||||||
session: "/tmp/native-pi-session.jsonl",
|
cwd: "/workspace/project",
|
||||||
argv: [
|
session: "/tmp/native-pi-session",
|
||||||
"pi",
|
});
|
||||||
"--mode",
|
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||||
"rpc",
|
expect(actualLaunch.argv).toEqual([
|
||||||
"--model",
|
"pi",
|
||||||
"openrouter/model-a",
|
"--mode",
|
||||||
"--thinking",
|
"rpc",
|
||||||
"high",
|
"--model",
|
||||||
"--session",
|
"openrouter/model-a",
|
||||||
"/tmp/native-pi-session.jsonl",
|
"--thinking",
|
||||||
],
|
"high",
|
||||||
}),
|
"--session",
|
||||||
|
"/tmp/native-pi-session",
|
||||||
|
"--extension",
|
||||||
|
actualLaunch.extensionPaths[0],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -354,21 +417,23 @@ describe("PiRpcAgentSession", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(pi.recordedLaunches[0]).toEqual(
|
const actualLaunch = pi.recordedLaunches[0]!;
|
||||||
expect.objectContaining({
|
expect(actualLaunch).toMatchObject({
|
||||||
cwd: "/tmp/paseo-pi-rpc-test",
|
cwd: "/tmp/paseo-pi-rpc-test",
|
||||||
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
||||||
argv: [
|
});
|
||||||
"pi",
|
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||||
"--mode",
|
expect(actualLaunch.argv).toEqual([
|
||||||
"rpc",
|
"pi",
|
||||||
"--thinking",
|
"--mode",
|
||||||
"medium",
|
"rpc",
|
||||||
"--append-system-prompt",
|
"--thinking",
|
||||||
"Agent prompt\n\nDaemon prompt",
|
"medium",
|
||||||
],
|
"--append-system-prompt",
|
||||||
}),
|
"Agent prompt\n\nDaemon prompt",
|
||||||
);
|
"--extension",
|
||||||
|
actualLaunch.extensionPaths[0],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resumes Pi sessions with daemon system prompts appended", async () => {
|
test("resumes Pi sessions with daemon system prompts appended", async () => {
|
||||||
@@ -379,7 +444,7 @@ describe("PiRpcAgentSession", () => {
|
|||||||
{
|
{
|
||||||
provider: "pi",
|
provider: "pi",
|
||||||
sessionId: "pi-session-1",
|
sessionId: "pi-session-1",
|
||||||
nativeHandle: "/tmp/native-pi-session.jsonl",
|
nativeHandle: "/tmp/native-pi-session",
|
||||||
metadata: {
|
metadata: {
|
||||||
cwd: "/workspace/project",
|
cwd: "/workspace/project",
|
||||||
model: "openrouter/model-a",
|
model: "openrouter/model-a",
|
||||||
@@ -392,25 +457,28 @@ describe("PiRpcAgentSession", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(pi.recordedLaunches).toEqual([
|
expect(pi.recordedLaunches).toHaveLength(1);
|
||||||
expect.objectContaining({
|
const actualLaunch = pi.recordedLaunches[0]!;
|
||||||
cwd: "/workspace/project",
|
expect(actualLaunch).toMatchObject({
|
||||||
session: "/tmp/native-pi-session.jsonl",
|
cwd: "/workspace/project",
|
||||||
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
session: "/tmp/native-pi-session",
|
||||||
argv: [
|
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
||||||
"pi",
|
});
|
||||||
"--mode",
|
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||||
"rpc",
|
expect(actualLaunch.argv).toEqual([
|
||||||
"--model",
|
"pi",
|
||||||
"openrouter/model-a",
|
"--mode",
|
||||||
"--thinking",
|
"rpc",
|
||||||
"high",
|
"--model",
|
||||||
"--session",
|
"openrouter/model-a",
|
||||||
"/tmp/native-pi-session.jsonl",
|
"--thinking",
|
||||||
"--append-system-prompt",
|
"high",
|
||||||
"Agent prompt\n\nDaemon prompt",
|
"--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 () => {
|
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
|
||||||
const pi = new FakePi();
|
const pi = new FakePi();
|
||||||
pi.queueCommands([
|
pi.queueCommands([
|
||||||
@@ -512,7 +601,8 @@ describe("PiRpcAgentClient", () => {
|
|||||||
cwd: "/tmp/paseo-pi-rpc-test",
|
cwd: "/tmp/paseo-pi-rpc-test",
|
||||||
argv: ["pi", "--mode", "rpc"],
|
argv: ["pi", "--mode", "rpc"],
|
||||||
});
|
});
|
||||||
const actualLaunch = pi.recordedLaunches[1];
|
const actualLaunch = pi.recordedLaunches[1]!;
|
||||||
|
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||||
expect(actualLaunch.argv).toEqual([
|
expect(actualLaunch.argv).toEqual([
|
||||||
"pi",
|
"pi",
|
||||||
"--mode",
|
"--mode",
|
||||||
@@ -521,12 +611,14 @@ describe("PiRpcAgentClient", () => {
|
|||||||
"medium",
|
"medium",
|
||||||
"--mcp-config",
|
"--mcp-config",
|
||||||
actualLaunch.mcpConfigPath,
|
actualLaunch.mcpConfigPath,
|
||||||
|
"--extension",
|
||||||
|
actualLaunch.extensionPaths[0],
|
||||||
]);
|
]);
|
||||||
expect(session.capabilities.supportsMcpServers).toBe(true);
|
expect(session.capabilities.supportsMcpServers).toBe(true);
|
||||||
|
|
||||||
const configPath = actualLaunch.mcpConfigPath;
|
const configPath = actualLaunch.mcpConfigPath;
|
||||||
expect(configPath).toEqual(expect.any(String));
|
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>;
|
mcpServers: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
expect(injectedConfig).toEqual({
|
expect(injectedConfig).toEqual({
|
||||||
@@ -565,54 +657,19 @@ describe("PiRpcAgentClient", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(pi.recordedLaunches).toHaveLength(2);
|
expect(pi.recordedLaunches).toHaveLength(2);
|
||||||
expect(pi.recordedLaunches[1]?.argv).toEqual(["pi", "--mode", "rpc", "--thinking", "medium"]);
|
const actualLaunch = pi.recordedLaunches[1]!;
|
||||||
expect(pi.recordedLaunches[1]?.mcpConfigPath).toBeUndefined();
|
expect(actualLaunch.extensionPaths).toHaveLength(1);
|
||||||
expect(session.capabilities.supportsMcpServers).toBe(false);
|
expect(actualLaunch.argv).toEqual([
|
||||||
});
|
"pi",
|
||||||
|
"--mode",
|
||||||
test("lists persisted Pi sessions from the configured Pi agent directory", async () => {
|
"rpc",
|
||||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-client-"));
|
"--thinking",
|
||||||
const cwd = path.join(root, "workspace");
|
"medium",
|
||||||
const agentDir = path.join(root, "agent");
|
"--extension",
|
||||||
const sessionsDir = path.join(agentDir, "sessions", "--workspace--");
|
actualLaunch.extensionPaths[0],
|
||||||
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" }],
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
expect(actualLaunch.mcpConfigPath).toBeUndefined();
|
||||||
|
expect(session.capabilities.supportsMcpServers).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,13 @@ import {
|
|||||||
resolveBinaryVersion,
|
resolveBinaryVersion,
|
||||||
toDiagnosticErrorMessage,
|
toDiagnosticErrorMessage,
|
||||||
} from "../diagnostic-utils.js";
|
} 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 { 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 { PiRuntime, PiRuntimeSession } from "./runtime.js";
|
||||||
import type {
|
import type {
|
||||||
PiAgentSessionEvent,
|
PiAgentSessionEvent,
|
||||||
@@ -68,6 +72,11 @@ import {
|
|||||||
const PI_PROVIDER = "pi";
|
const PI_PROVIDER = "pi";
|
||||||
const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium";
|
const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium";
|
||||||
const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi";
|
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 = {
|
const PI_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
@@ -76,6 +85,9 @@ const PI_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: true,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PI_THINKING_OPTIONS: ReadonlyArray<{
|
const PI_THINKING_OPTIONS: ReadonlyArray<{
|
||||||
@@ -149,6 +161,26 @@ interface PiMcpConfigFile {
|
|||||||
cleanup: () => void;
|
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 {
|
function normalizePiModelLabel(label: string): string {
|
||||||
return label.trim().replace(/[_\s]+/g, " ");
|
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 {
|
function isPiMcpAdapterCommand(command: PiRpcSlashCommand): boolean {
|
||||||
if (command.source !== "extension" || !/^mcp(?::\d+)?$/.test(command.name)) {
|
if (command.source !== "extension" || !/^mcp(?::\d+)?$/.test(command.name)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -396,11 +531,43 @@ function modelToId(model: PiModel | null | undefined): string | null {
|
|||||||
return model?.provider && model.id ? `${model.provider}/${model.id}` : 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 {
|
function latestPiErrorMessage(messages: PiAgentMessage[]): string | null {
|
||||||
const latestAssistant = messages.findLast((message) => message.role === "assistant");
|
const latestAssistant = messages.findLast((message) => message.role === "assistant");
|
||||||
return latestAssistant && "errorMessage" in latestAssistant
|
if (!latestAssistant || !latestAssistant.errorMessage?.trim()) {
|
||||||
? (latestAssistant.errorMessage ?? null)
|
return null;
|
||||||
: null;
|
}
|
||||||
|
return formatPiErrorMessage(latestAssistant);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
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;
|
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(
|
function mapExtensionUiRequestToPermission(
|
||||||
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
||||||
): AgentPermissionRequest | null {
|
): AgentPermissionRequest | null {
|
||||||
@@ -533,6 +740,12 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
private readonly pendingExtensionUiRequests = new Map<string, AgentPermissionRequest>();
|
private readonly pendingExtensionUiRequests = new Map<string, AgentPermissionRequest>();
|
||||||
private activeTurnId: string | null = null;
|
private activeTurnId: string | null = null;
|
||||||
private lastKnownThinkingOptionId: string | 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 state: PiSessionState;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
|
||||||
@@ -612,7 +825,12 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
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> {
|
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||||
@@ -683,6 +901,35 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
await this.runtimeSession.abort();
|
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> {
|
async close(): Promise<void> {
|
||||||
if (this.closed) {
|
if (this.closed) {
|
||||||
return;
|
return;
|
||||||
@@ -691,6 +938,7 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
try {
|
try {
|
||||||
await this.runtimeSession.close();
|
await this.runtimeSession.close();
|
||||||
} finally {
|
} finally {
|
||||||
|
this.rejectAllExtensionResults(new Error("Pi session closed"));
|
||||||
this.cleanup?.();
|
this.cleanup?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -742,9 +990,128 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
return this.activeTurnId ?? undefined;
|
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(
|
private handleExtensionUiRequest(
|
||||||
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
event: Extract<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
||||||
): void {
|
): void {
|
||||||
|
const message = optionalString(event.message);
|
||||||
|
if (event.method === "notify" && message) {
|
||||||
|
if (this.handleEntryCaptureMarker(message) || this.handleCommandResultMarker(message)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const request = mapExtensionUiRequestToPermission(event);
|
const request = mapExtensionUiRequestToPermission(event);
|
||||||
if (!request) {
|
if (!request) {
|
||||||
return;
|
return;
|
||||||
@@ -772,6 +1139,7 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleProcessExit(error: string): void {
|
private handleProcessExit(error: string): void {
|
||||||
|
this.rejectAllExtensionResults(new Error(error));
|
||||||
if (!this.activeTurnId) {
|
if (!this.activeTurnId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -803,6 +1171,11 @@ export class PiRpcAgentSession implements AgentSession {
|
|||||||
turnId,
|
turnId,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
case "message_start":
|
||||||
|
return;
|
||||||
|
case "message_end":
|
||||||
|
this.handleMessageEnd(event, turnId);
|
||||||
|
return;
|
||||||
case "message_update":
|
case "message_update":
|
||||||
this.handleMessageUpdate(event, turnId);
|
this.handleMessageUpdate(event, turnId);
|
||||||
return;
|
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(
|
private emitToolCallEvent(
|
||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
toolCall: PiTrackedToolCall,
|
toolCall: PiTrackedToolCall,
|
||||||
@@ -981,6 +1377,7 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
launchContext?: AgentLaunchContext,
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
|
const mcpConfig = await this.prepareMcpConfig(config.cwd, config.mcpServers);
|
||||||
|
const paseoExtension = createPiPaseoExtensionFile();
|
||||||
let runtimeSession: PiRuntimeSession;
|
let runtimeSession: PiRuntimeSession;
|
||||||
try {
|
try {
|
||||||
runtimeSession = await this.runtime.startSession({
|
runtimeSession = await this.runtime.startSession({
|
||||||
@@ -994,9 +1391,11 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
),
|
),
|
||||||
env: launchContext?.env,
|
env: launchContext?.env,
|
||||||
mcpConfigPath: mcpConfig?.path,
|
mcpConfigPath: mcpConfig?.path,
|
||||||
|
extensionPaths: [paseoExtension.path],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
mcpConfig?.cleanup();
|
mcpConfig?.cleanup();
|
||||||
|
paseoExtension.cleanup();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1005,11 +1404,12 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
config,
|
config,
|
||||||
initialState: await runtimeSession.getState(),
|
initialState: await runtimeSession.getState(),
|
||||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||||
cleanup: mcpConfig?.cleanup,
|
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await runtimeSession.close().catch(() => undefined);
|
await runtimeSession.close().catch(() => undefined);
|
||||||
mcpConfig?.cleanup();
|
mcpConfig?.cleanup();
|
||||||
|
paseoExtension.cleanup();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1028,6 +1428,7 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
|
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides);
|
||||||
|
|
||||||
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers);
|
const mcpConfig = await this.prepareMcpConfig(resumeConfig.cwd, resumeConfig.config.mcpServers);
|
||||||
|
const paseoExtension = createPiPaseoExtensionFile();
|
||||||
let runtimeSession: PiRuntimeSession;
|
let runtimeSession: PiRuntimeSession;
|
||||||
try {
|
try {
|
||||||
runtimeSession = await this.runtime.startSession({
|
runtimeSession = await this.runtime.startSession({
|
||||||
@@ -1040,9 +1441,11 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
resumeConfig.config.daemonAppendSystemPrompt,
|
resumeConfig.config.daemonAppendSystemPrompt,
|
||||||
),
|
),
|
||||||
mcpConfigPath: mcpConfig?.path,
|
mcpConfigPath: mcpConfig?.path,
|
||||||
|
extensionPaths: [paseoExtension.path],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
mcpConfig?.cleanup();
|
mcpConfig?.cleanup();
|
||||||
|
paseoExtension.cleanup();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1051,11 +1454,12 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
config: resumeConfig.config,
|
config: resumeConfig.config,
|
||||||
initialState: await runtimeSession.getState(),
|
initialState: await runtimeSession.getState(),
|
||||||
capabilities: withPiMcpCapability(mcpConfig !== null),
|
capabilities: withPiMcpCapability(mcpConfig !== null),
|
||||||
cleanup: mcpConfig?.cleanup,
|
cleanup: combineCleanup([mcpConfig?.cleanup, paseoExtension.cleanup]),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await runtimeSession.close().catch(() => undefined);
|
await runtimeSession.close().catch(() => undefined);
|
||||||
mcpConfig?.cleanup();
|
mcpConfig?.cleanup();
|
||||||
|
paseoExtension.cleanup();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1074,12 +1478,9 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listPersistedAgents(
|
async listPersistedAgents(
|
||||||
options?: ListPersistedAgentsOptions,
|
_options?: ListPersistedAgentsOptions,
|
||||||
): Promise<PersistedAgentDescriptor[]> {
|
): Promise<PersistedAgentDescriptor[]> {
|
||||||
return await listPiPersistedAgents({
|
return [];
|
||||||
...options,
|
|
||||||
runtimeSettings: this.runtimeSettings,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { describe, expect, test } from "vitest";
|
|||||||
|
|
||||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||||
import type { PiAgentMessage } from "./rpc-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[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
for await (const event of streamPiHistory("pi", messages)) {
|
for await (const event of streamPiHistory("pi", messages, userEntries)) {
|
||||||
events.push(event);
|
events.push(event);
|
||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
@@ -46,7 +49,6 @@ describe("Pi history mapper", () => {
|
|||||||
item: {
|
item: {
|
||||||
type: "user_message",
|
type: "user_message",
|
||||||
text: "read this\n\nthen answer",
|
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,
|
type PiTrackedToolCall,
|
||||||
} from "./tool-call-mapper.js";
|
} from "./tool-call-mapper.js";
|
||||||
|
|
||||||
|
export interface PiCapturedUserMessageEntry {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
function isTextContentBlock(block: unknown): block is PiTextContent {
|
function isTextContentBlock(block: unknown): block is PiTextContent {
|
||||||
return (
|
return (
|
||||||
typeof block === "object" &&
|
typeof block === "object" &&
|
||||||
@@ -35,6 +40,7 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon
|
|||||||
export async function* streamPiHistory(
|
export async function* streamPiHistory(
|
||||||
provider: string,
|
provider: string,
|
||||||
messages: PiAgentMessage[],
|
messages: PiAgentMessage[],
|
||||||
|
userEntries: readonly PiCapturedUserMessageEntry[] = [],
|
||||||
): AsyncGenerator<AgentStreamEvent> {
|
): AsyncGenerator<AgentStreamEvent> {
|
||||||
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
||||||
let userIndex = 0;
|
let userIndex = 0;
|
||||||
@@ -43,13 +49,14 @@ export async function* streamPiHistory(
|
|||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const text = getUserMessageText(message.content);
|
const text = getUserMessageText(message.content);
|
||||||
if (text) {
|
if (text) {
|
||||||
|
const userEntry = userEntries[userIndex];
|
||||||
yield {
|
yield {
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider,
|
provider,
|
||||||
item: {
|
item: {
|
||||||
type: "user_message",
|
type: "user_message",
|
||||||
text,
|
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";
|
role: "assistant";
|
||||||
content: PiAssistantContent[];
|
content: PiAssistantContent[];
|
||||||
|
provider?: string;
|
||||||
|
model?: string;
|
||||||
|
responseId?: string;
|
||||||
|
responseModel?: string;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
stopReason?: string;
|
stopReason?: string;
|
||||||
}
|
}
|
||||||
@@ -129,6 +133,8 @@ export type PiAssistantMessageEvent =
|
|||||||
export type PiAgentSessionEvent =
|
export type PiAgentSessionEvent =
|
||||||
| { type: "agent_start" }
|
| { type: "agent_start" }
|
||||||
| { type: "turn_start" }
|
| { type: "turn_start" }
|
||||||
|
| { type: "message_start"; message: PiAgentMessage }
|
||||||
|
| { type: "message_end"; message: PiAgentMessage }
|
||||||
| {
|
| {
|
||||||
type: "message_update";
|
type: "message_update";
|
||||||
message: PiAgentMessage;
|
message: PiAgentMessage;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface PiRuntimeLaunch {
|
|||||||
session?: string;
|
session?: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
mcpConfigPath?: string;
|
mcpConfigPath?: string;
|
||||||
|
extensionPaths?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PiStartSessionInput {
|
export interface PiStartSessionInput {
|
||||||
@@ -27,6 +28,7 @@ export interface PiStartSessionInput {
|
|||||||
session?: string;
|
session?: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
mcpConfigPath?: string;
|
mcpConfigPath?: string;
|
||||||
|
extensionPaths?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PiRuntimeSession {
|
export interface PiRuntimeSession {
|
||||||
@@ -85,6 +87,9 @@ export function buildPiLaunch(input: {
|
|||||||
if (input.session.mcpConfigPath) {
|
if (input.session.mcpConfigPath) {
|
||||||
argv.push("--mcp-config", input.session.mcpConfigPath);
|
argv.push("--mcp-config", input.session.mcpConfigPath);
|
||||||
}
|
}
|
||||||
|
for (const extensionPath of input.session.extensionPaths ?? []) {
|
||||||
|
argv.push("--extension", extensionPath);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cwd: input.session.cwd,
|
cwd: input.session.cwd,
|
||||||
@@ -101,6 +106,7 @@ export function buildPiLaunch(input: {
|
|||||||
session: input.session.session,
|
session: input.session.session,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
mcpConfigPath: input.session.mcpConfigPath,
|
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 prompts: Array<{ message: string; imageCount: number }> = [];
|
||||||
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
|
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
|
||||||
readonly setThinkingLevelRequests: string[] = [];
|
readonly setThinkingLevelRequests: string[] = [];
|
||||||
|
readonly treeNavigationRequests: string[] = [];
|
||||||
|
capturedUserEntries: Array<{ id: string; parentId: string | null; text: string }> = [];
|
||||||
abortRequested = false;
|
abortRequested = false;
|
||||||
readonly canceledExtensionUiRequests: string[] = [];
|
readonly canceledExtensionUiRequests: string[] = [];
|
||||||
readonly extensionUiResponses: Array<{
|
readonly extensionUiResponses: Array<{
|
||||||
@@ -77,7 +79,7 @@ export class FakePiSession implements PiRuntimeSession {
|
|||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
isStreaming: false,
|
isStreaming: false,
|
||||||
isCompacting: false,
|
isCompacting: false,
|
||||||
sessionFile: launch.session ?? "/tmp/pi-session.jsonl",
|
sessionFile: launch.session ?? "/tmp/pi-session",
|
||||||
sessionId: "pi-session-1",
|
sessionId: "pi-session-1",
|
||||||
messageCount: 0,
|
messageCount: 0,
|
||||||
pendingMessageCount: 0,
|
pendingMessageCount: 0,
|
||||||
@@ -96,6 +98,8 @@ export class FakePiSession implements PiRuntimeSession {
|
|||||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.prompts.push({ message, imageCount: images?.length ?? 0 });
|
this.prompts.push({ message, imageCount: images?.length ?? 0 });
|
||||||
|
this.handleTreeNavigationCommand(message);
|
||||||
|
this.handleEntryCaptureCommand(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
async abort(): Promise<void> {
|
async abort(): Promise<void> {
|
||||||
@@ -158,4 +162,62 @@ export class FakePiSession implements PiRuntimeSession {
|
|||||||
this.messages = [...this.messages, message];
|
this.messages = [...this.messages, message];
|
||||||
this.emit({ type: "agent_end", messages: this.messages });
|
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 { AgentStorage } from "../agent-storage.js";
|
||||||
|
|
||||||
import { ClaudeAgentClient } from "./claude/agent.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";
|
import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||||
|
|
||||||
const originalEnv = {
|
const originalEnv = {
|
||||||
@@ -88,7 +88,7 @@ describe("default provider availability", () => {
|
|||||||
try {
|
try {
|
||||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
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);
|
await expect(client.isAvailable()).resolves.toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
|
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> {
|
private async dispatchInboundMessage(msg: SessionInboundMessage): Promise<void> {
|
||||||
const promise =
|
const promise =
|
||||||
this.dispatchVoiceAndControlMessage(msg) ??
|
this.dispatchVoiceAndControlMessage(msg) ??
|
||||||
|
this.dispatchAgentRewindMessage(msg) ??
|
||||||
this.dispatchAgentLifecycleMessage(msg) ??
|
this.dispatchAgentLifecycleMessage(msg) ??
|
||||||
this.dispatchAgentConfigMessage(msg) ??
|
this.dispatchAgentConfigMessage(msg) ??
|
||||||
this.dispatchCheckoutMessage(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(
|
private async handleDictationStreamStart(
|
||||||
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
|
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
|
||||||
): Promise<void> {
|
): 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(
|
private async buildAgentSessionConfig(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
gitOptions?: GitSetupOptions,
|
gitOptions?: GitSetupOptions,
|
||||||
@@ -8794,7 +8831,7 @@ export class Session {
|
|||||||
agentId,
|
agentId,
|
||||||
prompt: formatSystemNotificationPrompt(text),
|
prompt: formatSystemNotificationPrompt(text),
|
||||||
unarchive: false,
|
unarchive: false,
|
||||||
recordUserMessage: false,
|
userMessageVisible: false,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
|||||||
supportsMcpServers: false,
|
supportsMcpServers: false,
|
||||||
supportsReasoningStream: true,
|
supportsReasoningStream: true,
|
||||||
supportsToolInvocations: true,
|
supportsToolInvocations: true,
|
||||||
|
supportsRewindConversation: false,
|
||||||
|
supportsRewindFiles: false,
|
||||||
|
supportsRewindBoth: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TEST_FEATURE_ID = "test_feature";
|
const TEST_FEATURE_ID = "test_feature";
|
||||||
|
|||||||
@@ -1083,6 +1083,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
daemonStatusRpc: true,
|
daemonStatusRpc: true,
|
||||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||||
"terminal-restore-modes": true,
|
"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).toHaveLength(1);
|
||||||
expect(parsed.features?.[0]?.id).toBe("fast_mode");
|
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(),
|
supportsMcpServers: z.boolean(),
|
||||||
supportsReasoningStream: z.boolean(),
|
supportsReasoningStream: z.boolean(),
|
||||||
supportsToolInvocations: 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({
|
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
||||||
@@ -1246,6 +1252,26 @@ export const SetAgentFeatureResponseMessageSchema = z.object({
|
|||||||
payload: AgentActionResponsePayloadSchema,
|
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({
|
export const UpdateAgentResponseMessageSchema = z.object({
|
||||||
type: z.literal("update_agent_response"),
|
type: z.literal("update_agent_response"),
|
||||||
payload: AgentActionResponsePayloadSchema,
|
payload: AgentActionResponsePayloadSchema,
|
||||||
@@ -1854,6 +1880,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentModelRequestMessageSchema,
|
SetAgentModelRequestMessageSchema,
|
||||||
SetAgentThinkingRequestMessageSchema,
|
SetAgentThinkingRequestMessageSchema,
|
||||||
SetAgentFeatureRequestMessageSchema,
|
SetAgentFeatureRequestMessageSchema,
|
||||||
|
AgentRewindRequestMessageSchema,
|
||||||
AgentPermissionResponseMessageSchema,
|
AgentPermissionResponseMessageSchema,
|
||||||
CheckoutStatusRequestSchema,
|
CheckoutStatusRequestSchema,
|
||||||
SubscribeCheckoutDiffRequestSchema,
|
SubscribeCheckoutDiffRequestSchema,
|
||||||
@@ -2095,6 +2122,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
daemonStatusRpc: z.boolean().optional(),
|
daemonStatusRpc: z.boolean().optional(),
|
||||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||||
"terminal-restore-modes": z.boolean().optional(),
|
"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(),
|
.optional(),
|
||||||
})
|
})
|
||||||
@@ -3624,6 +3653,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
SetAgentModelResponseMessageSchema,
|
SetAgentModelResponseMessageSchema,
|
||||||
SetAgentThinkingResponseMessageSchema,
|
SetAgentThinkingResponseMessageSchema,
|
||||||
SetAgentFeatureResponseMessageSchema,
|
SetAgentFeatureResponseMessageSchema,
|
||||||
|
AgentRewindResponseMessageSchema,
|
||||||
UpdateAgentResponseMessageSchema,
|
UpdateAgentResponseMessageSchema,
|
||||||
ProjectRenameResponseSchema,
|
ProjectRenameResponseSchema,
|
||||||
WaitForFinishResponseMessageSchema,
|
WaitForFinishResponseMessageSchema,
|
||||||
@@ -3763,6 +3793,7 @@ export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMes
|
|||||||
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
|
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
|
||||||
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
|
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
|
||||||
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
|
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
|
||||||
|
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
|
||||||
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
||||||
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
|
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
|
||||||
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
|
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { z } from "zod";
|
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 { CLIENT_CAPS } from "./client-capabilities.js";
|
||||||
import {
|
import {
|
||||||
|
AgentSnapshotPayloadSchema,
|
||||||
AgentTimelineItemPayloadSchema,
|
AgentTimelineItemPayloadSchema,
|
||||||
FetchAgentTimelineResponseMessageSchema,
|
FetchAgentTimelineResponseMessageSchema,
|
||||||
SessionInboundMessageSchema,
|
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 {
|
interface SessionInternals {
|
||||||
buildAgentPayload: (snapshot: unknown) => Promise<null>;
|
|
||||||
handleFetchAgentTimelineRequest: (
|
handleFetchAgentTimelineRequest: (
|
||||||
message: Extract<
|
message: Extract<
|
||||||
z.infer<typeof SessionInboundMessageSchema>,
|
z.infer<typeof SessionInboundMessageSchema>,
|
||||||
@@ -68,12 +81,140 @@ interface SessionInternals {
|
|||||||
) => Promise<void>;
|
) => 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?: {
|
function createSessionForWireCompatTest(options?: {
|
||||||
clientCapabilities?: Record<string, unknown> | null;
|
clientCapabilities?: Record<string, unknown> | null;
|
||||||
messages?: SessionOutboundMessage[];
|
messages?: SessionOutboundMessage[];
|
||||||
}): Session {
|
}): Session {
|
||||||
const messages = options?.messages ?? [];
|
const messages = options?.messages ?? [];
|
||||||
const snapshot = { id: "agent-1", provider: "codex" };
|
|
||||||
const rows: AgentTimelineRow[] = [
|
const rows: AgentTimelineRow[] = [
|
||||||
{
|
{
|
||||||
seq: 1,
|
seq: 1,
|
||||||
@@ -100,74 +241,62 @@ function createSessionForWireCompatTest(options?: {
|
|||||||
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
|
downloadTokenStore: {} as SessionOptions["downloadTokenStore"],
|
||||||
pushTokenStore: {} as SessionOptions["pushTokenStore"],
|
pushTokenStore: {} as SessionOptions["pushTokenStore"],
|
||||||
paseoHome: "/tmp/paseo-home",
|
paseoHome: "/tmp/paseo-home",
|
||||||
agentManager: {
|
agentManager: new InMemoryAgentManager(rows) as unknown as SessionOptions["agentManager"],
|
||||||
getAgent: vi.fn(() => snapshot),
|
agentStorage: new EmptyAgentStorage() as unknown as SessionOptions["agentStorage"],
|
||||||
fetchTimeline: vi.fn(() => ({
|
projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"],
|
||||||
epoch: "epoch-1",
|
workspaceRegistry:
|
||||||
reset: false,
|
new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"],
|
||||||
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"],
|
|
||||||
chatService: {} as SessionOptions["chatService"],
|
chatService: {} as SessionOptions["chatService"],
|
||||||
scheduleService: {} as SessionOptions["scheduleService"],
|
scheduleService: {} as SessionOptions["scheduleService"],
|
||||||
loopService: {} as SessionOptions["loopService"],
|
loopService: {} as SessionOptions["loopService"],
|
||||||
checkoutDiffManager: {
|
checkoutDiffManager: {
|
||||||
scheduleRefreshForCwd: vi.fn(),
|
scheduleRefreshForCwd() {},
|
||||||
} as unknown as SessionOptions["checkoutDiffManager"],
|
} as unknown as SessionOptions["checkoutDiffManager"],
|
||||||
github: {
|
github: {
|
||||||
invalidate: vi.fn(),
|
invalidate() {},
|
||||||
searchIssuesAndPrs: vi.fn(),
|
async searchIssuesAndPrs() {
|
||||||
createPullRequest: vi.fn(),
|
return [];
|
||||||
|
},
|
||||||
|
async createPullRequest() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
} as unknown as SessionOptions["github"],
|
} as unknown as SessionOptions["github"],
|
||||||
workspaceGitService: {
|
workspaceGitService: {
|
||||||
getCheckoutDiff: vi.fn(),
|
async getCheckoutDiff() {
|
||||||
getSnapshot: vi.fn(),
|
return null;
|
||||||
suggestBranchesForCwd: vi.fn(),
|
},
|
||||||
listStashes: vi.fn(),
|
async getSnapshot() {
|
||||||
peekSnapshot: vi.fn(),
|
return null;
|
||||||
validateBranchRef: vi.fn(),
|
},
|
||||||
hasLocalBranch: vi.fn(),
|
async suggestBranchesForCwd() {
|
||||||
resolveRepoRemoteUrl: vi.fn(),
|
return [];
|
||||||
getWorkspaceGitMetadata: vi.fn(),
|
},
|
||||||
|
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"],
|
} as unknown as SessionOptions["workspaceGitService"],
|
||||||
daemonConfigStore: {
|
daemonConfigStore:
|
||||||
get: vi.fn(() => ({
|
new EmptyDaemonConfigStore() as unknown as SessionOptions["daemonConfigStore"],
|
||||||
mcp: { injectIntoAgents: false },
|
|
||||||
providers: {},
|
|
||||||
})),
|
|
||||||
onChange: vi.fn(() => () => {}),
|
|
||||||
} as unknown as SessionOptions["daemonConfigStore"],
|
|
||||||
stt: null,
|
stt: null,
|
||||||
tts: null,
|
tts: null,
|
||||||
terminalManager: null,
|
terminalManager: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const internals = session as unknown as SessionInternals;
|
|
||||||
internals.buildAgentPayload = vi.fn(async () => null);
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,8 +385,82 @@ describe("wire compatibility", () => {
|
|||||||
expect(parsed.detail.actions).toEqual([]);
|
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 () => {
|
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 = {
|
const dependencies = {
|
||||||
paseoHome: "/tmp/paseo-home",
|
paseoHome: "/tmp/paseo-home",
|
||||||
@@ -275,12 +478,9 @@ describe("wire compatibility", () => {
|
|||||||
activityAt: null,
|
activityAt: null,
|
||||||
scripts: [],
|
scripts: [],
|
||||||
}) as never,
|
}) as never,
|
||||||
emit: vi.fn(),
|
emit() {},
|
||||||
sessionLogger: pino({ level: "silent" }),
|
sessionLogger: pino({ level: "silent" }),
|
||||||
createPaseoWorktreeWorkflow: async (input: unknown) => {
|
createPaseoWorktreeWorkflow: workflow.create.bind(workflow),
|
||||||
captured.push(input);
|
|
||||||
return {} as never;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const legacyRequest = SessionInboundMessageSchema.parse({
|
const legacyRequest = SessionInboundMessageSchema.parse({
|
||||||
@@ -329,9 +529,9 @@ describe("wire compatibility", () => {
|
|||||||
await handleCreatePaseoWorktreeRequest(dependencies, legacyRequest);
|
await handleCreatePaseoWorktreeRequest(dependencies, legacyRequest);
|
||||||
await handleCreatePaseoWorktreeRequest(dependencies, newRequest);
|
await handleCreatePaseoWorktreeRequest(dependencies, newRequest);
|
||||||
|
|
||||||
expect(captured).toHaveLength(2);
|
expect(workflow.capturedInputs).toHaveLength(2);
|
||||||
expect(captured[0]).toEqual(captured[1]);
|
expect(workflow.capturedInputs[0]).toEqual(workflow.capturedInputs[1]);
|
||||||
expect(captured[0]).toEqual({
|
expect(workflow.capturedInputs[0]).toEqual({
|
||||||
cwd: "/tmp/repo",
|
cwd: "/tmp/repo",
|
||||||
worktreeSlug: "legacy-worktree",
|
worktreeSlug: "legacy-worktree",
|
||||||
firstAgentContext: {
|
firstAgentContext: {
|
||||||
|
|||||||
Reference in New Issue
Block a user