fix(coding-agent): handle symlinked session selector paths closes #3364
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setKeybindings } from "@mariozechner/pi-tui";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { KeybindingsManager } from "../src/core/keybindings.js";
|
||||
import type { SessionInfo } from "../src/core/session-manager.js";
|
||||
import { SessionSelectorComponent } from "../src/modes/interactive/components/session-selector.js";
|
||||
@@ -27,12 +30,17 @@ async function flushPromises(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "");
|
||||
}
|
||||
|
||||
function makeSession(overrides: Partial<SessionInfo> & { id: string }): SessionInfo {
|
||||
return {
|
||||
path: overrides.path ?? `/tmp/${overrides.id}.jsonl`,
|
||||
id: overrides.id,
|
||||
cwd: overrides.cwd ?? "",
|
||||
name: overrides.name,
|
||||
parentSessionPath: overrides.parentSessionPath,
|
||||
created: overrides.created ?? new Date(0),
|
||||
modified: overrides.modified ?? new Date(0),
|
||||
messageCount: overrides.messageCount ?? 1,
|
||||
@@ -41,11 +49,52 @@ function makeSession(overrides: Partial<SessionInfo> & { id: string }): SessionI
|
||||
};
|
||||
}
|
||||
|
||||
function createSymlinkedSessionPaths(): {
|
||||
baseDir: string;
|
||||
parentAliasA: string;
|
||||
parentAliasB: string;
|
||||
childAliasB: string;
|
||||
} {
|
||||
const baseDir = mkdtempSync(join(tmpdir(), "pi-session-selector-"));
|
||||
const realDir = join(baseDir, "real");
|
||||
const aliasADir = join(baseDir, "alias-a");
|
||||
const aliasBDir = join(baseDir, "alias-b");
|
||||
mkdirSync(realDir, { recursive: true });
|
||||
mkdirSync(aliasADir, { recursive: true });
|
||||
mkdirSync(aliasBDir, { recursive: true });
|
||||
|
||||
const sharedDir = join(realDir, "sessions");
|
||||
mkdirSync(sharedDir, { recursive: true });
|
||||
const aliasASessions = join(aliasADir, "sessions");
|
||||
const aliasBSessions = join(aliasBDir, "sessions");
|
||||
symlinkSync(sharedDir, aliasASessions);
|
||||
symlinkSync(sharedDir, aliasBSessions);
|
||||
|
||||
const parentRealPath = join(sharedDir, "parent.jsonl");
|
||||
const childRealPath = join(sharedDir, "child.jsonl");
|
||||
writeFileSync(parentRealPath, "parent\n");
|
||||
writeFileSync(childRealPath, "child\n");
|
||||
|
||||
return {
|
||||
baseDir,
|
||||
parentAliasA: join(aliasASessions, "parent.jsonl"),
|
||||
parentAliasB: join(aliasBSessions, "parent.jsonl"),
|
||||
childAliasB: join(aliasBSessions, "child.jsonl"),
|
||||
};
|
||||
}
|
||||
|
||||
const CTRL_D = "\x04";
|
||||
const CTRL_BACKSPACE = "\x1b[127;5u";
|
||||
|
||||
describe("session selector path/delete interactions", () => {
|
||||
const keybindings = new KeybindingsManager();
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Ensure test isolation: keybindings are a global singleton
|
||||
@@ -196,4 +245,71 @@ describe("session selector path/delete interactions", () => {
|
||||
allDeferred.resolve([makeSession({ id: "all" })]);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
it("threads sessions when parent and child paths use different symlink aliases", async () => {
|
||||
const paths = createSymlinkedSessionPaths();
|
||||
tempDirs.push(paths.baseDir);
|
||||
|
||||
const sessions = [
|
||||
makeSession({
|
||||
id: "parent",
|
||||
path: paths.parentAliasB,
|
||||
name: "Parent",
|
||||
modified: new Date("2026-01-01T00:00:00.000Z"),
|
||||
}),
|
||||
makeSession({
|
||||
id: "child",
|
||||
path: paths.childAliasB,
|
||||
parentSessionPath: paths.parentAliasA,
|
||||
name: "Child",
|
||||
modified: new Date("2025-12-31T00:00:00.000Z"),
|
||||
}),
|
||||
];
|
||||
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => sessions,
|
||||
async () => [],
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ keybindings },
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const output = stripAnsi(selector.render(120).join("\n"));
|
||||
expect(output).toContain("Parent");
|
||||
expect(output).toContain("└─ Child");
|
||||
});
|
||||
|
||||
it("treats the current session as active across symlink aliases", async () => {
|
||||
const paths = createSymlinkedSessionPaths();
|
||||
tempDirs.push(paths.baseDir);
|
||||
|
||||
const sessions = [makeSession({ id: "parent", path: paths.parentAliasB, name: "Parent" })];
|
||||
const selector = new SessionSelectorComponent(
|
||||
async () => sessions,
|
||||
async () => [],
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
() => {},
|
||||
{ keybindings },
|
||||
paths.parentAliasA,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
const list = selector.getSessionList();
|
||||
const confirmationChanges: Array<string | null> = [];
|
||||
let errorMessage: string | undefined;
|
||||
list.onDeleteConfirmationChange = (path) => confirmationChanges.push(path);
|
||||
list.onError = (message) => {
|
||||
errorMessage = message;
|
||||
};
|
||||
|
||||
list.handleInput(CTRL_D);
|
||||
|
||||
expect(confirmationChanges).toEqual([]);
|
||||
expect(errorMessage).toBe("Cannot delete the currently active session");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user