mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Show a Claude agent's history when its transcript is under another project folder (#6301)
* fix(claude): load history from a transcript under another project folder Claude Code resumes a session by id from any working directory and keeps writing to the transcript in the project folder where the session started. When the agent's cwd encodes to a different folder (a moved project, a migrated daemon, a Windows binary under WSL), the agent kept working but reopened with an empty timeline. Look the transcript up by session id across the config dir's project folders when it is not where cwd points. Refs #4540 * fix(claude): report a failed project folder listing instead of an absent transcript Take the transcript lookup's inputs as one object, and rethrow read errors other than a missing projects folder so the history load logs them as a failure. * test(claude): skip the unreadable projects folder case when running as root --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
import { chmodSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
@@ -90,4 +90,29 @@ describe("ClaudeAgentSession persisted history load", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// chmod does not remove read access on Windows, or for root.
|
||||||
|
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||||
|
"logs a warning with the error when Claude's project folders cannot be listed",
|
||||||
|
async () => {
|
||||||
|
// The transcript is not under cwd's folder, so the lookup lists the other project folders.
|
||||||
|
mkdirSync(path.join(configDir, "projects"), { recursive: true });
|
||||||
|
chmodSync(path.join(configDir, "projects"), 0o000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resume();
|
||||||
|
} finally {
|
||||||
|
chmodSync(path.join(configDir, "projects"), 0o755);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(records).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
level: pino.levels.values.warn,
|
||||||
|
msg: "Failed to load Claude history from transcript",
|
||||||
|
sessionId: SESSION_ID,
|
||||||
|
err: expect.objectContaining({ message: expect.stringContaining("EACCES") }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ import {
|
|||||||
type ClaudeRewindSdk,
|
type ClaudeRewindSdk,
|
||||||
} from "./rewind.js";
|
} from "./rewind.js";
|
||||||
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
import { normalizeProviderReplayTimestamp } from "../../provider-history-timestamps.js";
|
||||||
import { claudeConfigDir, claudeProjectDirSync } from "./project-dir.js";
|
import { claudeConfigDir, claudeProjectDirSync, claudeTranscriptPathSync } from "./project-dir.js";
|
||||||
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
import { THINKING_APPLIES_NEXT_TURN_NOTICE } from "../../provider-notices.js";
|
||||||
import {
|
import {
|
||||||
isProviderImageMarkdown,
|
isProviderImageMarkdown,
|
||||||
@@ -5216,26 +5216,11 @@ 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 configDir = claudeConfigDir(this.harnessEnvironment);
|
return claudeTranscriptPathSync({
|
||||||
const candidates = [cwd];
|
cwd,
|
||||||
try {
|
sessionId,
|
||||||
const realCwd = fs.realpathSync(cwd);
|
configDir: claudeConfigDir(this.harnessEnvironment),
|
||||||
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 historyPath = path.join(
|
|
||||||
claudeProjectDirSync(candidate, { configDir }),
|
|
||||||
`${sessionId}.jsonl`,
|
|
||||||
);
|
|
||||||
if (fs.existsSync(historyPath)) {
|
|
||||||
return historyPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return path.join(claudeProjectDirSync(cwd, { configDir }), `${sessionId}.jsonl`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
|
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { realpathSync } from "node:fs";
|
import { existsSync, readdirSync, realpathSync } from "node:fs";
|
||||||
import { realpath } from "node:fs/promises";
|
import { realpath } from "node:fs/promises";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -30,6 +30,46 @@ export function claudeProjectDirSync(cwd: string, options?: ClaudeProjectDirOpti
|
|||||||
return join(projectsRoot, encode(canonical));
|
return join(projectsRoot, encode(canonical));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Code resumes a session by id from any working directory and keeps appending to the
|
||||||
|
// transcript in the project folder where the session started. That folder differs from the
|
||||||
|
// one `cwd` encodes to when the project moved, the daemon migrated, or Claude Code sees the
|
||||||
|
// cwd under another name (a Windows binary under WSL). Returns the expected path when no
|
||||||
|
// transcript exists anywhere.
|
||||||
|
export function claudeTranscriptPathSync(input: {
|
||||||
|
cwd: string;
|
||||||
|
sessionId: string;
|
||||||
|
configDir?: string;
|
||||||
|
}): string {
|
||||||
|
const options = { configDir: input.configDir };
|
||||||
|
const fileName = `${input.sessionId}.jsonl`;
|
||||||
|
const expected = join(claudeProjectDirSync(input.cwd, options), fileName);
|
||||||
|
if (existsSync(expected)) {
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
const projectsRoot = join(resolveConfigDir(options), "projects");
|
||||||
|
let projectDirs: string[];
|
||||||
|
try {
|
||||||
|
projectDirs = readdirSync(projectsRoot);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingDirectoryError(error)) {
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
for (const projectDir of projectDirs) {
|
||||||
|
const candidate = join(projectsRoot, projectDir, fileName);
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingDirectoryError(error: unknown): boolean {
|
||||||
|
const code = (error as NodeJS.ErrnoException | null)?.code;
|
||||||
|
return code === "ENOENT" || code === "ENOTDIR";
|
||||||
|
}
|
||||||
|
|
||||||
async function canonicalize(input: string): Promise<string> {
|
async function canonicalize(input: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
return normalizeProjectPath(await realpath(input));
|
return normalizeProjectPath(await realpath(input));
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { claudeProjectDirSync } from "../agent/providers/claude/project-dir.js";
|
||||||
|
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
|
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
|
|
||||||
|
// Claude Code resumes a session by id from any working directory and keeps writing to the
|
||||||
|
// transcript under the project folder it started in. The agent's cwd can name a different
|
||||||
|
// folder: a project that moved, a daemon migrated between machines or containers, or Claude
|
||||||
|
// Code running under WSL interop and seeing the cwd as a Windows path.
|
||||||
|
// The test daemon fakes the built-in providers; a provider that extends `claude` runs the real one.
|
||||||
|
const PROVIDER_ID = "claude-real";
|
||||||
|
const SESSION_ID = "6f0c5d8e-4c3a-4f7b-9a51-2d8e1b7c9f10";
|
||||||
|
|
||||||
|
function timelineText(entries: ReadonlyArray<{ item: { type: string; text?: string } }>): string {
|
||||||
|
return entries
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
entry,
|
||||||
|
): entry is {
|
||||||
|
item: { type: "user_message" | "assistant_message"; text: string };
|
||||||
|
} => entry.item.type === "user_message" || entry.item.type === "assistant_message",
|
||||||
|
)
|
||||||
|
.map((entry) => entry.item.text)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("daemon E2E - Claude history stored under another project folder", () => {
|
||||||
|
let tempRoot: string;
|
||||||
|
let paseoHomeRoot: string;
|
||||||
|
let configDir: string;
|
||||||
|
let transcriptCwd: string;
|
||||||
|
let agentCwd: string;
|
||||||
|
let daemon: TestPaseoDaemon | undefined;
|
||||||
|
let client: DaemonClient | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = mkdtempSync(path.join(tmpdir(), "claude-history-other-project-"));
|
||||||
|
paseoHomeRoot = path.join(tempRoot, "paseo-home");
|
||||||
|
configDir = path.join(tempRoot, "claude-config");
|
||||||
|
transcriptCwd = path.join(tempRoot, "where-claude-started");
|
||||||
|
agentCwd = path.join(tempRoot, "agent-cwd");
|
||||||
|
mkdirSync(paseoHomeRoot, { recursive: true });
|
||||||
|
mkdirSync(agentCwd, { recursive: true });
|
||||||
|
|
||||||
|
const projectDir = claudeProjectDirSync(transcriptCwd, { configDir });
|
||||||
|
mkdirSync(projectDir, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
path.join(projectDir, `${SESSION_ID}.jsonl`),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "user",
|
||||||
|
uuid: "user-uuid-1",
|
||||||
|
sessionId: SESSION_ID,
|
||||||
|
cwd: transcriptCwd,
|
||||||
|
message: { role: "user", content: "hello from the original folder" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
sessionId: SESSION_ID,
|
||||||
|
cwd: transcriptCwd,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: "reply from the original folder",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.map((entry) => `${JSON.stringify(entry)}\n`)
|
||||||
|
.join(""),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await client?.close().catch(() => undefined);
|
||||||
|
await daemon?.close().catch(() => undefined);
|
||||||
|
client = undefined;
|
||||||
|
daemon = undefined;
|
||||||
|
rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
async function startDaemon(): Promise<DaemonClient> {
|
||||||
|
daemon = await createTestPaseoDaemon({
|
||||||
|
paseoHomeRoot,
|
||||||
|
cleanup: false,
|
||||||
|
providerOverrides: {
|
||||||
|
[PROVIDER_ID]: {
|
||||||
|
extends: "claude",
|
||||||
|
label: "Claude",
|
||||||
|
env: { CLAUDE_CONFIG_DIR: configDir },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: {} });
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopDaemon(): Promise<void> {
|
||||||
|
await client?.close();
|
||||||
|
await daemon?.close();
|
||||||
|
client = undefined;
|
||||||
|
daemon = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an agent's timeline survives a daemon restart", async () => {
|
||||||
|
const firstClient = await startDaemon();
|
||||||
|
const agent = await firstClient.importAgent({
|
||||||
|
provider: PROVIDER_ID,
|
||||||
|
sessionId: SESSION_ID,
|
||||||
|
cwd: agentCwd,
|
||||||
|
});
|
||||||
|
await stopDaemon();
|
||||||
|
|
||||||
|
const restartedClient = await startDaemon();
|
||||||
|
const timeline = await restartedClient.fetchAgentTimeline(agent.id, {
|
||||||
|
direction: "tail",
|
||||||
|
limit: 0,
|
||||||
|
projection: "canonical",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = timelineText(timeline.entries);
|
||||||
|
expect(text).toContain("hello from the original folder");
|
||||||
|
expect(text).toContain("reply from the original folder");
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user