mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
SessionManager.open() without a session directory used the file's folder for new sessions. When the session's cwd differs from the project the folder belongs to, for example after choosing "continue in current cwd" at the missing-cwd prompt, pi treated that folder as a custom --session-dir: /resume All listed only that folder, the resume hint added --session-dir, and /new, /fork and /clone saved into the old project's folder. For a file in the sessions directory, open() now uses the default directory of the session's cwd. Files elsewhere, such as in a custom --session-dir, keep their folder.
552 lines
19 KiB
TypeScript
552 lines
19 KiB
TypeScript
import { constants as bufferConstants } from "buffer";
|
|
import {
|
|
appendFileSync,
|
|
chmodSync,
|
|
closeSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
openSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
writeSync,
|
|
} from "fs";
|
|
import { tmpdir } from "os";
|
|
import { join } from "path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { ENV_AGENT_DIR } from "../../src/config.ts";
|
|
import { findMostRecentSession, loadEntriesFromFile, SessionManager } from "../../src/core/session-manager.ts";
|
|
import { assistantMsg, readSessionFileRoles, userMsg } from "../utilities.ts";
|
|
|
|
const HEADER_SCAN_LIMIT_BYTES = 1024 * 1024;
|
|
|
|
describe("loadEntriesFromFile", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writeSessionHeader(file: string, cwd: string, id: string, prefix = ""): void {
|
|
writeFileSync(
|
|
file,
|
|
`${prefix}${JSON.stringify({
|
|
type: "session",
|
|
version: 3,
|
|
id,
|
|
timestamp: "2025-01-01T00:00:00Z",
|
|
cwd,
|
|
})}\n`,
|
|
);
|
|
}
|
|
|
|
it("returns empty array for non-existent file", () => {
|
|
const entries = loadEntriesFromFile(join(tempDir, "nonexistent.jsonl"));
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array for empty file", () => {
|
|
const file = join(tempDir, "empty.jsonl");
|
|
writeFileSync(file, "");
|
|
expect(loadEntriesFromFile(file)).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array for file without valid session header", () => {
|
|
const file = join(tempDir, "no-header.jsonl");
|
|
writeFileSync(file, '{"type":"message","id":"1"}\n');
|
|
expect(loadEntriesFromFile(file)).toEqual([]);
|
|
});
|
|
|
|
it("returns empty array for malformed JSON", () => {
|
|
const file = join(tempDir, "malformed.jsonl");
|
|
writeFileSync(file, "not json\n");
|
|
expect(loadEntriesFromFile(file)).toEqual([]);
|
|
});
|
|
|
|
it("loads valid session file", () => {
|
|
const file = join(tempDir, "valid.jsonl");
|
|
writeFileSync(
|
|
file,
|
|
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' +
|
|
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n',
|
|
);
|
|
const entries = loadEntriesFromFile(file);
|
|
expect(entries).toHaveLength(2);
|
|
expect(entries[0].type).toBe("session");
|
|
expect(entries[1].type).toBe("message");
|
|
});
|
|
|
|
it("skips malformed lines but keeps valid ones", () => {
|
|
const file = join(tempDir, "mixed.jsonl");
|
|
writeFileSync(
|
|
file,
|
|
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' +
|
|
"not valid json\n" +
|
|
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n',
|
|
);
|
|
const entries = loadEntriesFromFile(file);
|
|
expect(entries).toHaveLength(2);
|
|
});
|
|
|
|
it("adds a newline after an unterminated valid record", () => {
|
|
const file = join(tempDir, "unterminated.jsonl");
|
|
const content =
|
|
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' +
|
|
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}';
|
|
writeFileSync(file, content);
|
|
|
|
expect(loadEntriesFromFile(file)).toHaveLength(2);
|
|
expect(readFileSync(file, "utf8")).toBe(`${content}\n`);
|
|
});
|
|
|
|
it("adds a newline after an unterminated malformed final fragment", () => {
|
|
const file = join(tempDir, "malformed-tail.jsonl");
|
|
const content =
|
|
'{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n' + '{"type":"message"';
|
|
writeFileSync(file, content);
|
|
|
|
expect(loadEntriesFromFile(file)).toHaveLength(1);
|
|
expect(readFileSync(file, "utf8")).toBe(`${content}\n`);
|
|
});
|
|
|
|
it("does not modify an unterminated non-session file", () => {
|
|
const file = join(tempDir, "invalid.jsonl");
|
|
const content = '{"type":"message","id":"1"}';
|
|
writeFileSync(file, content);
|
|
|
|
expect(loadEntriesFromFile(file)).toEqual([]);
|
|
expect(readFileSync(file, "utf8")).toBe(content);
|
|
});
|
|
|
|
it.each([
|
|
["leading blank lines", "\n \n", "leading-blank"],
|
|
["leading malformed lines", "not json\n{broken json\n", "leading-malformed"],
|
|
["a multi-buffer header", "", "a".repeat(8192)],
|
|
])("reads cwd from a session with %s", (_description, prefix, sessionId) => {
|
|
const file = join(tempDir, "header.jsonl");
|
|
const storedCwd = join(tempDir, "stored-project");
|
|
writeSessionHeader(file, storedCwd, sessionId, prefix);
|
|
|
|
const sessionManager = SessionManager.open(file, tempDir);
|
|
expect(sessionManager.getSessionId()).toBe(sessionId);
|
|
expect(sessionManager.getCwd()).toBe(storedCwd);
|
|
});
|
|
|
|
it("opens compatible sessions beyond the discovery scan limit", () => {
|
|
const storedCwd = join(tempDir, "stored-project");
|
|
const overrideCwd = join(tempDir, "override-project");
|
|
const cases = [
|
|
{ name: "large-header", id: "a".repeat(HEADER_SCAN_LIMIT_BYTES + 1), prefix: "" },
|
|
{
|
|
name: "large-prefix",
|
|
id: "large-prefix",
|
|
prefix: `${"x".repeat(HEADER_SCAN_LIMIT_BYTES + 1)}\n`,
|
|
},
|
|
];
|
|
|
|
for (const { name, id, prefix } of cases) {
|
|
const file = join(tempDir, `${name}.jsonl`);
|
|
writeSessionHeader(file, storedCwd, id, prefix);
|
|
for (const cwdOverride of [undefined, overrideCwd]) {
|
|
const sessionManager = SessionManager.open(file, tempDir, cwdOverride);
|
|
expect(sessionManager.getSessionId()).toBe(id);
|
|
expect(sessionManager.getCwd()).toBe(cwdOverride ?? storedCwd);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("opens session files larger than Node's max string length", () => {
|
|
const file = join(tempDir, "large.jsonl");
|
|
writeFileSync(
|
|
file,
|
|
'{"type":"session","version":3,"id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n',
|
|
);
|
|
|
|
const fd = openSync(file, "r+");
|
|
try {
|
|
const newline = Buffer.from("\n");
|
|
const stride = 16 * 1024 * 1024;
|
|
for (let offset = stride; offset <= bufferConstants.MAX_STRING_LENGTH + stride; offset += stride) {
|
|
writeSync(fd, newline, 0, newline.length, offset);
|
|
}
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
|
|
appendFileSync(
|
|
file,
|
|
'{"type":"message","id":"1","parentId":null,"timestamp":"2025-01-01T00:00:01Z","message":{"role":"user","content":"hi","timestamp":1}}\n',
|
|
);
|
|
|
|
const sessionManager = SessionManager.open(file, tempDir);
|
|
expect(sessionManager.getSessionId()).toBe("abc");
|
|
expect(sessionManager.getEntries()).toHaveLength(1);
|
|
expect(sessionManager.buildSessionContext().messages).toEqual([{ role: "user", content: "hi", timestamp: 1 }]);
|
|
});
|
|
});
|
|
|
|
describe("findMostRecentSession", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("returns null for empty directory", () => {
|
|
expect(findMostRecentSession(tempDir)).toBeNull();
|
|
});
|
|
|
|
it("returns null for non-existent directory", () => {
|
|
expect(findMostRecentSession(join(tempDir, "nonexistent"))).toBeNull();
|
|
});
|
|
|
|
it("ignores non-jsonl files", () => {
|
|
writeFileSync(join(tempDir, "file.txt"), "hello");
|
|
writeFileSync(join(tempDir, "file.json"), "{}");
|
|
expect(findMostRecentSession(tempDir)).toBeNull();
|
|
});
|
|
|
|
it("ignores jsonl files without valid session header", () => {
|
|
writeFileSync(join(tempDir, "invalid.jsonl"), '{"type":"message"}\n');
|
|
expect(findMostRecentSession(tempDir)).toBeNull();
|
|
});
|
|
|
|
it("returns single valid session file", () => {
|
|
const file = join(tempDir, "session.jsonl");
|
|
writeFileSync(file, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
|
|
expect(findMostRecentSession(tempDir)).toBe(file);
|
|
});
|
|
|
|
it("returns most recently modified session", async () => {
|
|
const file1 = join(tempDir, "older.jsonl");
|
|
const file2 = join(tempDir, "newer.jsonl");
|
|
|
|
writeFileSync(file1, '{"type":"session","id":"old","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
|
|
// Small delay to ensure different mtime
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
writeFileSync(file2, '{"type":"session","id":"new","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
|
|
|
|
expect(findMostRecentSession(tempDir)).toBe(file2);
|
|
});
|
|
|
|
it("skips invalid files and returns valid one", async () => {
|
|
const invalid = join(tempDir, "invalid.jsonl");
|
|
const valid = join(tempDir, "valid.jsonl");
|
|
|
|
writeFileSync(invalid, '{"type":"not-session"}\n');
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
|
|
|
|
expect(findMostRecentSession(tempDir)).toBe(valid);
|
|
});
|
|
|
|
it("skips oversized corrupt files and returns a valid session", () => {
|
|
const invalid = join(tempDir, "oversized.jsonl");
|
|
const valid = join(tempDir, "valid.jsonl");
|
|
writeFileSync(invalid, "x".repeat(HEADER_SCAN_LIMIT_BYTES + 1));
|
|
writeFileSync(valid, '{"type":"session","id":"abc","timestamp":"2025-01-01T00:00:00Z","cwd":"/tmp"}\n');
|
|
|
|
expect(findMostRecentSession(tempDir)).toBe(valid);
|
|
});
|
|
|
|
it("filters most recent session by cwd", async () => {
|
|
const projectA = join(tempDir, "project-a");
|
|
const projectB = join(tempDir, "project-b");
|
|
const fileA = join(tempDir, "a.jsonl");
|
|
const fileB = join(tempDir, "b.jsonl");
|
|
|
|
writeFileSync(
|
|
fileA,
|
|
`${JSON.stringify({ type: "session", id: "a", timestamp: "2025-01-01T00:00:00Z", cwd: projectA })}\n`,
|
|
);
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
writeFileSync(
|
|
fileB,
|
|
`${JSON.stringify({ type: "session", id: "b", timestamp: "2025-01-01T00:00:00Z", cwd: projectB })}\n`,
|
|
);
|
|
|
|
expect(findMostRecentSession(tempDir, projectA)).toBe(fileA);
|
|
expect(findMostRecentSession(tempDir, projectB)).toBe(fileB);
|
|
});
|
|
});
|
|
|
|
describe("SessionManager custom flat session directory", () => {
|
|
let tempDir: string;
|
|
let projectA: string;
|
|
let projectB: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
|
|
projectA = join(tempDir, "project-a");
|
|
projectB = join(tempDir, "project-b");
|
|
mkdirSync(projectA, { recursive: true });
|
|
mkdirSync(projectB, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function createPersistedSession(cwd: string, label: string): string {
|
|
const session = SessionManager.create(cwd, tempDir);
|
|
session.appendMessage({ role: "user", content: label, timestamp: Date.now() });
|
|
session.appendMessage({
|
|
role: "assistant",
|
|
content: [{ type: "text", text: `reply to ${label}` }],
|
|
api: "anthropic-messages",
|
|
provider: "anthropic",
|
|
model: "test",
|
|
usage: {
|
|
input: 1,
|
|
output: 1,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 2,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
stopReason: "stop",
|
|
timestamp: Date.now(),
|
|
});
|
|
const sessionFile = session.getSessionFile();
|
|
if (!sessionFile) {
|
|
throw new Error("Expected persisted session file");
|
|
}
|
|
return sessionFile;
|
|
}
|
|
|
|
it("scopes current-folder APIs by cwd while listing all flat sessions", async () => {
|
|
const sessionA = createPersistedSession(projectA, "from A");
|
|
await new Promise((r) => setTimeout(r, 10));
|
|
const sessionB = createPersistedSession(projectB, "from B");
|
|
|
|
const currentA = await SessionManager.list(projectA, tempDir);
|
|
expect(currentA.map((session) => session.path)).toEqual([sessionA]);
|
|
|
|
const all = await SessionManager.listAll(tempDir);
|
|
expect(new Set(all.map((session) => session.path))).toEqual(new Set([sessionA, sessionB]));
|
|
|
|
const continuedA = SessionManager.continueRecent(projectA, tempDir);
|
|
expect(continuedA.getSessionFile()).toBe(sessionA);
|
|
});
|
|
|
|
it("rejects a cancelled session listing", async () => {
|
|
createPersistedSession(projectA, "from A");
|
|
createPersistedSession(projectB, "from B");
|
|
const controller = new AbortController();
|
|
const listing = SessionManager.listAll(
|
|
tempDir,
|
|
(_loaded, _total, partialSessions) => {
|
|
if (partialSessions) controller.abort();
|
|
},
|
|
controller.signal,
|
|
);
|
|
|
|
await expect(listing).rejects.toMatchObject({ name: "AbortError" });
|
|
await expect(SessionManager.listAll(undefined, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
|
|
});
|
|
});
|
|
|
|
describe("SessionManager.open session directory", () => {
|
|
let tempDir: string;
|
|
let savedAgentDir: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "session-default-dir-"));
|
|
savedAgentDir = process.env[ENV_AGENT_DIR];
|
|
process.env[ENV_AGENT_DIR] = join(tempDir, "agent");
|
|
mkdirSync(join(tempDir, "a"));
|
|
mkdirSync(join(tempDir, "b"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedAgentDir === undefined) delete process.env[ENV_AGENT_DIR];
|
|
else process.env[ENV_AGENT_DIR] = savedAgentDir;
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("uses the default directory of the cwd for a session opened with another cwd", () => {
|
|
const session = SessionManager.create(join(tempDir, "a"));
|
|
session.appendMessage(userMsg("hello"));
|
|
|
|
const reopened = SessionManager.open(session.getSessionFile()!, undefined, join(tempDir, "b"));
|
|
expect(reopened.getSessionDir()).toBe(SessionManager.create(join(tempDir, "b")).getSessionDir());
|
|
expect(reopened.usesDefaultSessionDir()).toBe(true);
|
|
});
|
|
|
|
it("keeps a custom session directory", () => {
|
|
const customDir = join(tempDir, "custom");
|
|
const session = SessionManager.create(join(tempDir, "a"), customDir);
|
|
session.appendMessage(userMsg("hello"));
|
|
|
|
const reopened = SessionManager.open(session.getSessionFile()!, undefined, join(tempDir, "b"));
|
|
expect(reopened.getSessionDir()).toBe(customDir);
|
|
expect(reopened.usesDefaultSessionDir()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("SessionManager.setSessionFile with corrupted files", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = join(tmpdir(), `session-test-${Date.now()}`);
|
|
mkdirSync(tempDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("truncates and rewrites empty file with valid header", () => {
|
|
const emptyFile = join(tempDir, "empty.jsonl");
|
|
writeFileSync(emptyFile, "");
|
|
|
|
const sm = SessionManager.open(emptyFile, tempDir);
|
|
|
|
// Should have created a new session with valid header
|
|
expect(sm.getSessionId()).toBeTruthy();
|
|
expect(sm.getHeader()).toBeTruthy();
|
|
expect(sm.getHeader()?.type).toBe("session");
|
|
|
|
// File should now contain a valid header
|
|
const content = readFileSync(emptyFile, "utf-8");
|
|
const lines = content.trim().split("\n").filter(Boolean);
|
|
expect(lines.length).toBe(1);
|
|
const header = JSON.parse(lines[0]);
|
|
expect(header.type).toBe("session");
|
|
expect(header.id).toBe(sm.getSessionId());
|
|
});
|
|
|
|
it("throws and preserves non-empty file without valid header", () => {
|
|
const noHeaderFile = join(tempDir, "no-header.jsonl");
|
|
const originalContent =
|
|
'{"type":"message","id":"abc","parentId":"orphaned","timestamp":"2025-01-01T00:00:00Z","message":{"role":"assistant","content":"test"}}\n';
|
|
writeFileSync(noHeaderFile, originalContent);
|
|
|
|
expect(() => SessionManager.open(noHeaderFile, tempDir)).toThrow(
|
|
`Session file is not a valid pi session: ${noHeaderFile}`,
|
|
);
|
|
expect(readFileSync(noHeaderFile, "utf-8")).toBe(originalContent);
|
|
});
|
|
|
|
it("throws and preserves non-session JSONL files", () => {
|
|
const nonSessionFile = join(tempDir, "not-a-session.log");
|
|
const originalContent = '{"type":"event","data":"not a session"}\n';
|
|
writeFileSync(nonSessionFile, originalContent);
|
|
|
|
expect(() => SessionManager.open(nonSessionFile, tempDir)).toThrow(
|
|
`Session file is not a valid pi session: ${nonSessionFile}`,
|
|
);
|
|
expect(readFileSync(nonSessionFile, "utf-8")).toBe(originalContent);
|
|
});
|
|
|
|
it("preserves explicit session file path when recovering from corrupted file", () => {
|
|
const explicitPath = join(tempDir, "my-session.jsonl");
|
|
writeFileSync(explicitPath, "");
|
|
|
|
const sm = SessionManager.open(explicitPath, tempDir);
|
|
|
|
// The session file path should be preserved
|
|
expect(sm.getSessionFile()).toBe(explicitPath);
|
|
});
|
|
|
|
it("subsequent loads of initialized empty file work correctly", () => {
|
|
const emptyFile = join(tempDir, "empty.jsonl");
|
|
writeFileSync(emptyFile, "");
|
|
|
|
const sm1 = SessionManager.open(emptyFile, tempDir);
|
|
const sessionId = sm1.getSessionId();
|
|
|
|
const sm2 = SessionManager.open(emptyFile, tempDir);
|
|
expect(sm2.getSessionId()).toBe(sessionId);
|
|
expect(sm2.getHeader()?.type).toBe("session");
|
|
});
|
|
});
|
|
|
|
describe("SessionManager session file creation", () => {
|
|
let tempDir: string;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), "pi-session-persist-"));
|
|
});
|
|
|
|
afterEach(() => {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("does not create a file for a session with only setup entries", () => {
|
|
const session = SessionManager.create(tempDir, tempDir);
|
|
session.appendModelChange("anthropic", "claude-sonnet-4-5");
|
|
session.appendThinkingLevelChange("off");
|
|
|
|
expect(existsSync(session.getSessionFile()!)).toBe(false);
|
|
});
|
|
|
|
// #10000: the first prompt must survive a first turn that never produces an assistant message
|
|
it("creates the file when the first user message is appended", () => {
|
|
const session = SessionManager.create(tempDir, tempDir);
|
|
session.appendModelChange("anthropic", "claude-sonnet-4-5");
|
|
session.appendMessage(userMsg("first question"));
|
|
|
|
const file = session.getSessionFile()!;
|
|
expect(readSessionFileRoles(file)).toEqual(["session", "model_change", "user"]);
|
|
expect(SessionManager.open(file, tempDir).buildSessionContext().messages).toHaveLength(1);
|
|
});
|
|
|
|
it("appends later entries to the file without rewriting earlier ones", () => {
|
|
const session = SessionManager.create(tempDir, tempDir);
|
|
session.appendMessage(userMsg("first question"));
|
|
session.appendCustomEntry("preset-state", { name: "plan" });
|
|
session.appendMessage(assistantMsg("first answer"));
|
|
|
|
expect(readSessionFileRoles(session.getSessionFile()!)).toEqual(["session", "user", "custom", "assistant"]);
|
|
});
|
|
|
|
// #10166, #10661, #11460: a failed write must not leave later entries pointing to a missing parent
|
|
it("does not keep an entry in memory when writing it fails", () => {
|
|
const session = SessionManager.create(tempDir, tempDir);
|
|
const firstId = session.appendMessage(userMsg("first question"));
|
|
const file = session.getSessionFile()!;
|
|
|
|
chmodSync(file, 0o444);
|
|
expect(() => session.appendMessage(assistantMsg("lost answer"))).toThrow();
|
|
expect(session.getLeafId()).toBe(firstId);
|
|
expect(session.getEntries().map((entry) => entry.id)).toEqual([firstId]);
|
|
|
|
chmodSync(file, 0o644);
|
|
const secondId = session.appendMessage(assistantMsg("second answer"));
|
|
expect(session.getEntry(secondId)?.parentId).toBe(firstId);
|
|
|
|
const reopened = SessionManager.open(file, tempDir);
|
|
expect(reopened.getBranch().map((entry) => entry.id)).toEqual([firstId, secondId]);
|
|
});
|
|
|
|
it("does not keep an entry in memory when creating the file fails", () => {
|
|
const sessionDir = join(tempDir, "sessions");
|
|
mkdirSync(sessionDir);
|
|
const session = SessionManager.create(tempDir, sessionDir);
|
|
const modelChangeId = session.appendModelChange("anthropic", "claude-sonnet-4-5");
|
|
|
|
chmodSync(sessionDir, 0o555);
|
|
try {
|
|
expect(() => session.appendMessage(userMsg("first question"))).toThrow();
|
|
} finally {
|
|
chmodSync(sessionDir, 0o755);
|
|
}
|
|
expect(session.getLeafId()).toBe(modelChangeId);
|
|
expect(session.getEntries().map((entry) => entry.id)).toEqual([modelChangeId]);
|
|
|
|
session.appendMessage(userMsg("second question"));
|
|
expect(readSessionFileRoles(session.getSessionFile()!)).toEqual(["session", "model_change", "user"]);
|
|
});
|
|
});
|