diff --git a/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts new file mode 100644 index 0000000000..e20b8d6faf --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts @@ -0,0 +1,239 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +const SESSION_CWD = "/tmp/paseo-claude-renamed-import"; + +interface ClaudeTranscriptRecord { + [key: string]: unknown; +} + +let configDir: string; +let previousConfigDir: string | undefined; + +interface UserRecordInput { + sessionId: string; + content: string; +} + +interface WriteSessionInput { + sessionId: string; + records: ClaudeTranscriptRecord[]; +} + +function userRecord({ sessionId, content }: UserRecordInput): ClaudeTranscriptRecord { + return { + isSidechain: false, + type: "user", + message: { role: "user", content }, + cwd: SESSION_CWD, + sessionId, + }; +} + +async function writeSession({ sessionId, records }: WriteSessionInput): Promise { + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, + ); +} + +// Importing replays the transcript from disk, so this drives the real resume +// path without starting Claude Code. +async function importSession(sessionId: string) { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + }); + return client.importSession( + { providerHandleId: sessionId, cwd: SESSION_CWD }, + { + config: { provider: "claude", cwd: SESSION_CWD }, + storedConfig: { provider: "claude", cwd: SESSION_CWD }, + }, + ); +} + +beforeEach(async () => { + configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-renamed-")); + previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; +}); + +afterEach(async () => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + await fs.rm(configDir, { recursive: true, force: true }); +}); + +describe("importing a renamed Claude session", () => { + test.each(["provider", "launch"])( + "reads the rename from the %s config directory used for history", + async (source) => { + const sessionId = "provider-directory-rename"; + const providerConfigDir = path.join(configDir, "provider"); + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir: providerConfigDir }); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ] + .map((record) => JSON.stringify(record)) + .join("\n") + "\n", + ); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: + source === "provider" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined, + resolveBinary: async () => "/test/claude/bin", + }); + const imported = await client.importSession( + { providerHandleId: sessionId, cwd: SESSION_CWD }, + { + config: { provider: "claude", cwd: SESSION_CWD }, + storedConfig: { provider: "claude", cwd: SESSION_CWD }, + launchContext: + source === "launch" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined, + }, + ); + expect(imported.timeline.map((entry) => entry.item)).toContainEqual( + expect.objectContaining({ type: "user_message", text: "Review this project" }), + ); + expect(imported.config.title).toBe("My research session"); + }, + ); + + test("names the imported agent after a /rename", async () => { + const sessionId = "renamed-session"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("uses the last rename when the session was renamed more than once", async () => { + const sessionId = "renamed-twice"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Move invoices to the new schema" }), + { type: "custom-title", customTitle: "invoices", sessionId }, + { type: "custom-title", customTitle: "billing rework", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("billing rework"); + }); + + test("preserves a rename when the first prompt was /model", async () => { + const sessionId = "model-first"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "/model" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("keeps the rename when a later record carries no usable name", async () => { + const sessionId = "malformed-later-record"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + { type: "custom-title", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("ignores a rename recorded on a sidechain transcript", async () => { + const sessionId = "sidechain-rename"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "Subagent title", sessionId, isSidechain: true }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBeUndefined(); + }); + + test("leaves the agent untitled when the session was never renamed", async () => { + const sessionId = "never-renamed"; + await writeSession({ + sessionId, + records: [userRecord({ sessionId, content: "Review this project" })], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBeUndefined(); + }); + + test("does not name the imported agent after a generated ai-title", async () => { + const sessionId = "auto-titled"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Add pagination to the invoice list" }), + { type: "ai-title", aiTitle: "Invoice list pagination", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + // The Import Session list shows it, but agents are named from their first + // prompt line. + expect(imported.config.title).toBeUndefined(); + }); + + test("imports a session that has no transcript on disk", async () => { + const imported = await importSession("no-such-session"); + + expect(imported.config.title).toBeUndefined(); + }); + + test("surfaces an unreadable transcript instead of importing it as never renamed", async () => { + const sessionId = "unreadable-transcript"; + // A directory where the transcript belongs fails the read with EISDIR, the + // deterministic stand-in for a permissions or I/O fault. + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(path.join(projectDir, `${sessionId}.jsonl`), { recursive: true }); + + await expect(importSession(sessionId)).rejects.toThrow(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index a8f2143832..61ac0e3ae0 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1693,11 +1693,21 @@ export class ClaudeAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + // Carry a `/rename` onto the imported agent so the name chosen in the CLI + // survives the import. Only an explicit rename counts: agents are named + // after their first prompt line since #1563, so adopting the generated + // `ai-title` here would put that LLM summary back. + const renamedTitle = await readClaudeRenamedSessionTitle({ + cwd: input.cwd, + sessionId: input.providerHandleId, + configDir: claudeConfigDir(this.buildProviderEnv(context.launchContext?.env)), + }); return importSessionFromPersistence({ provider: "claude", request: input, context, resumeSession: this.resumeSession.bind(this), + ...(renamedTitle ? { config: { title: renamedTitle } } : {}), }); } @@ -6603,6 +6613,66 @@ function normalizeClaudeSessionTitle(title: string | null): string | null { return normalized ? normalized : null; } +const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title"; + +/** + * The `custom-title` record is re-emitted every time the session is saved, so + * the current name is the last usable one in the file. Searching backwards finds it without + * walking the transcript, which matters because sessions reach tens of + * megabytes. + */ +function readLastClaudeCustomTitle(content: string): string | null { + const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`; + let at = content.lastIndexOf(needle); + while (at >= 0) { + const lineStart = content.lastIndexOf("\n", at) + 1; + const lineEnd = content.indexOf("\n", at); + const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd); + let parsed: unknown; + try { + parsed = JSON.parse(line.trim()); + } catch { + parsed = undefined; + } + const entry = toObjectRecord(parsed); + if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) { + const title = normalizeClaudeSessionTitle( + typeof entry.customTitle === "string" ? entry.customTitle : null, + ); + if (title) { + return title; + } + } + // The needle also matches a prompt that quotes it, and a record can carry no + // usable name, so keep looking backwards for one that does. + at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1); + } + return null; +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +async function readClaudeRenamedSessionTitle(input: { + cwd: string; + sessionId: string; + configDir: string; +}): Promise { + let content: string; + try { + content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8"); + } catch (error) { + // A session with no transcript on disk has no rename to carry over. Every + // other read failure is a real fault and stays explicit. + if (isErrnoException(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + return readLastClaudeCustomTitle(content); +} + function normalizeImportablePromptPreview(text: string): string | null { const normalized = text.trim().replace(/\s+/g, " "); if (!normalized) return null; diff --git a/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts index 00b365881f..ad703a3885 100644 --- a/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts @@ -67,6 +67,7 @@ describe("daemon E2E - Claude history stored under another project folder", () = content: "reply from the original folder", }, }, + { type: "custom-title", customTitle: "Renamed moved session", sessionId: SESSION_ID }, ] .map((entry) => `${JSON.stringify(entry)}\n`) .join(""), @@ -114,6 +115,7 @@ describe("daemon E2E - Claude history stored under another project folder", () = sessionId: SESSION_ID, cwd: agentCwd, }); + expect(agent.title).toBe("Renamed moved session"); await stopDaemon(); const restartedClient = await startDaemon(); diff --git a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts index 29779fd1ef..1715fabec9 100644 --- a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts @@ -55,6 +55,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR" cwd, message: { role: "assistant", content: "reply from the custom config dir" }, }, + { type: "custom-title", customTitle: "Renamed provider session", sessionId: SESSION_ID }, ] .map((entry) => `${JSON.stringify(entry)}\n`) .join(""), @@ -111,6 +112,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR" sessionId: SESSION_ID, cwd, }); + expect(agent.title).toBe("Renamed provider session"); await stopDaemon(); const restartedClient = await startDaemon();