mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
213bb3fe2f | ||
|
|
0708b8295c | ||
|
|
4d5c3c13d4 |
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TerminalInputModeTracker } from "./terminal-input-mode";
|
||||
import { encodeTerminalKeyInput } from "./terminal-key-input";
|
||||
|
||||
describe("Modified Enter negotiation", () => {
|
||||
it("answers a keyboard query split immediately after ESC", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed("\x1b");
|
||||
expect(tracker.feed("[?u").responses).toEqual(["\x1b[?0u"]);
|
||||
});
|
||||
|
||||
it("recognizes modified Enter after a program enables modifyOtherKeys level 2", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed("\x1b[>4;2m");
|
||||
expect(tracker.supportsModifiedEnter()).toBe(true);
|
||||
});
|
||||
|
||||
it("distinguishes Shift+Enter from Enter after modifyOtherKeys negotiation", () => {
|
||||
const tracker = new TerminalInputModeTracker();
|
||||
tracker.feed("\x1b[>4;2m");
|
||||
const options = { inputMode: tracker.getState() };
|
||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true }, options)).toBe("\x1b[27;2;13~");
|
||||
expect(encodeTerminalKeyInput({ key: "Enter" }, options)).toBe("\r");
|
||||
});
|
||||
});
|
||||
@@ -3098,7 +3098,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.cachedCommands = update.availableCommands.map((command) => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
argumentHint: "",
|
||||
argumentHint: command.input?.hint ?? "",
|
||||
kind: "command",
|
||||
}));
|
||||
this.settleCommandsReady();
|
||||
|
||||
@@ -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<void> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> {
|
||||
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;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
|
||||
describe("GenericACPAgentClient slash commands", () => {
|
||||
test("lists commands an agent advertises right after session/new", async () => {
|
||||
test("preserves argument hints in commands advertised right after session/new", async () => {
|
||||
await withFakeACPAgent("commands-after-session-new", async (command, cwd) => {
|
||||
const client = new GenericACPAgentClient({
|
||||
logger: createTestLogger(),
|
||||
@@ -17,9 +17,17 @@ describe("GenericACPAgentClient slash commands", () => {
|
||||
});
|
||||
const session = await client.createSession({ provider: "acp", cwd });
|
||||
try {
|
||||
await expect(session.listCommands?.()).resolves.toEqual([
|
||||
{ name: "review", description: "Review the diff", argumentHint: "", kind: "command" },
|
||||
]);
|
||||
const commands = [
|
||||
{
|
||||
name: "review",
|
||||
description: "Review the diff",
|
||||
argumentHint: "query",
|
||||
kind: "command",
|
||||
},
|
||||
{ name: "status", description: "Show status", argumentHint: "", kind: "command" },
|
||||
];
|
||||
await expect(session.listCommands?.()).resolves.toEqual(commands);
|
||||
await expect(session.listCommands?.()).resolves.toEqual(commands);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
@@ -91,7 +99,10 @@ rl.on("line", (line) => {
|
||||
sessionId: "session-1",
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [{ name: "review", description: "Review the diff" }],
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review the diff", input: { hint: "query" } },
|
||||
{ name: "status", description: "Show status" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user