Show an archived ACP agent's history after its worktree is removed (#6228)

* Show an archived ACP agent's history after its worktree is removed

Opening an archived ACP agent loads its history by resuming the provider
session for reading. The provider process was spawned in the agent's
stored working directory, so once that directory was removed (for
example when its worktree workspace was archived) the spawn failed with
ENOENT and the chat opened empty.

A history load now starts the provider process from the home directory
when the working directory is gone, and still names the original
directory in session/load. An interactive resume keeps requiring the
directory.

Closes #5449

* Only treat a missing working directory as gone for a history load

A stat failure other than a missing path, such as a permission error,
now reaches the caller instead of moving the provider to the home
directory.

* Start the history process from home when the stored path is not a directory

A file left where an archived agent's worktree was made the history load
spawn in it and fail with ENOTDIR.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-06 18:54:59 +02:00
committed by GitHub
parent a292b402cf
commit a9fef4879a
2 changed files with 184 additions and 1 deletions
@@ -76,6 +76,8 @@ import {
type AgentPersistenceHandle,
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentResumePurpose,
type AgentResumeSessionOptions,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
@@ -148,6 +150,18 @@ function rejectOnSpawnError(child: ChildProcess, stderrChunks: string[]): Promis
});
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await fs.stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -497,6 +511,7 @@ interface ACPAgentSessionOptions {
capabilities: AgentCapabilityFlags;
extensionCommandsParser?: ACPExtensionCommandsParser;
handle?: AgentPersistenceHandle;
resumePurpose?: AgentResumePurpose;
agentId?: string;
launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
@@ -1006,6 +1021,7 @@ export class ACPAgentClient implements AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
if (handle.provider !== this.provider) {
throw new Error(`Cannot resume ${handle.provider} handle with ${this.provider} provider`);
@@ -1047,6 +1063,7 @@ export class ACPAgentClient implements AgentClient {
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
},
handle,
resumePurpose: options?.purpose,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
@@ -1700,6 +1717,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private readonly terminalEntries = new Map<string, TerminalEntry>();
private readonly persistedHistory: AgentTimelineItem[] = [];
private readonly initialHandle?: AgentPersistenceHandle;
private readonly resumePurpose: AgentResumePurpose;
private readonly config: AgentSessionConfig;
private child: ChildProcessWithoutNullStreams | null = null;
@@ -1753,6 +1771,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.agentId = options.agentId;
this.launchEnv = options.launchEnv;
this.initialHandle = options.handle;
this.resumePurpose = options.resumePurpose ?? "interactive";
this.config = { ...config, provider: options.provider };
this.currentMode = config.modeId ?? null;
this.currentModel = config.model ?? null;
@@ -2770,6 +2789,16 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return {};
}
// Loading an archived agent's history runs nothing in its working directory, which
// may have been removed with its worktree. session/load still names the original
// directory; only the process starts from the home directory.
private async resolveProcessCwd(): Promise<string> {
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
return this.config.cwd;
}
return homedir();
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
const prefix = await resolveProviderLaunch({
commandConfig: this.runtimeSettings?.command,
@@ -2783,7 +2812,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const command = prefix.command;
const args = [...prefix.args, ...this.defaultCommand.slice(1)];
const child = spawnProcess(command, args, {
cwd: this.config.cwd,
cwd: await this.resolveProcessCwd(),
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [this.launchEnv],
@@ -0,0 +1,154 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
describe("GenericACPAgentClient archived history", () => {
test("replays an archived session whose working directory was removed", async () => {
await withFakeACPAgent(async ({ command, removedCwd, loadRequestsPath }) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "history" },
);
try {
expect(await collectTimelineText(session.streamHistory())).toEqual([
"user_message:What is in the README?",
"assistant_message:It describes the project.",
]);
const loadRequests = JSON.parse(await readFile(loadRequestsPath, "utf8"));
expect(loadRequests).toEqual([{ sessionId: "archived-session", cwd: removedCwd }]);
} finally {
await session.close();
}
});
});
test("replays an archived session whose working directory path is now a file", async () => {
await withFakeACPAgent(async ({ command, removedCwd }) => {
await writeFile(removedCwd, "not a directory", "utf8");
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
const session = await client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "history" },
);
try {
expect(await collectTimelineText(session.streamHistory())).toEqual([
"user_message:What is in the README?",
"assistant_message:It describes the project.",
]);
} finally {
await session.close();
}
});
});
test("still requires the working directory to resume a session that will run", async () => {
await withFakeACPAgent(async ({ command, removedCwd }) => {
const client = new GenericACPAgentClient({ logger: createTestLogger(), command });
await expect(
client.resumeSession(
{ provider: "acp", sessionId: "archived-session", metadata: { cwd: removedCwd } },
undefined,
undefined,
{ purpose: "interactive" },
),
).rejects.toThrow("ENOENT");
});
});
});
async function collectTimelineText(events: AsyncGenerator<AgentStreamEvent>): Promise<string[]> {
const text: string[] = [];
for await (const event of events) {
if (event.type !== "timeline") {
continue;
}
if (event.item.type === "user_message" || event.item.type === "assistant_message") {
text.push(`${event.item.type}:${event.item.text}`);
}
}
return text;
}
async function withFakeACPAgent(
run: (input: {
command: [string, ...string[]];
removedCwd: string;
loadRequestsPath: string;
}) => Promise<void>,
): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), "paseo-acp-archived-history-"));
try {
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
const loadRequestsPath = path.join(testDir, "load-requests.json");
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
await run({
command: [process.execPath, scriptPath, loadRequestsPath],
removedCwd: path.join(testDir, "removed-worktree"),
loadRequestsPath,
});
} finally {
await rm(testDir, { recursive: true, force: true });
}
}
const fakeACPAgentScript = `
const fs = require("node:fs");
const readline = require("node:readline");
const loadRequestsPath = process.argv[2];
const loadRequests = [];
const rl = readline.createInterface({ input: process.stdin });
function write(message) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n");
}
function replay(sessionId, sessionUpdate, text) {
write({
method: "session/update",
params: { sessionId, update: { sessionUpdate, content: { type: "text", text } } },
});
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") {
write({
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? 1,
agentCapabilities: { loadSession: true, sessionCapabilities: { close: {} } },
},
});
return;
}
if (message.method === "session/load") {
const { sessionId, cwd } = message.params;
loadRequests.push({ sessionId, cwd });
fs.writeFileSync(loadRequestsPath, JSON.stringify(loadRequests));
replay(sessionId, "user_message_chunk", "What is in the README?");
replay(sessionId, "agent_message_chunk", "It describes the project.");
write({ id: message.id, result: {} });
return;
}
if (message.id !== undefined) {
write({ id: message.id, result: {} });
}
});
`;