mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
type BranchSummaryEntry,
|
||||
buildContextEntries,
|
||||
buildSessionContext,
|
||||
type CompactionEntry,
|
||||
type CustomEntry,
|
||||
type ModelChangeEntry,
|
||||
type SessionEntry,
|
||||
type SessionMessageEntry,
|
||||
type ThinkingLevelChangeEntry,
|
||||
} from "../../src/core/session-manager.ts";
|
||||
|
||||
function msg(id: string, parentId: string | null, role: "user" | "assistant", text: string): SessionMessageEntry {
|
||||
const base = { type: "message" as const, id, parentId, timestamp: "2025-01-01T00:00:00Z" };
|
||||
if (role === "user") {
|
||||
return { ...base, message: { role, content: text, timestamp: 1 } };
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
message: {
|
||||
role,
|
||||
content: [{ type: "text", text }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-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: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function compaction(id: string, parentId: string | null, summary: string, firstKeptEntryId: string): CompactionEntry {
|
||||
return {
|
||||
type: "compaction",
|
||||
id,
|
||||
parentId,
|
||||
timestamp: "2025-01-01T00:00:00Z",
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
tokensBefore: 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function branchSummary(id: string, parentId: string | null, summary: string, fromId: string): BranchSummaryEntry {
|
||||
return { type: "branch_summary", id, parentId, timestamp: "2025-01-01T00:00:00Z", summary, fromId };
|
||||
}
|
||||
|
||||
function custom(id: string, parentId: string | null, customType: string, data?: unknown): CustomEntry {
|
||||
return { type: "custom", id, parentId, timestamp: "2025-01-01T00:00:00Z", customType, data };
|
||||
}
|
||||
|
||||
function thinkingLevel(id: string, parentId: string | null, level: string): ThinkingLevelChangeEntry {
|
||||
return { type: "thinking_level_change", id, parentId, timestamp: "2025-01-01T00:00:00Z", thinkingLevel: level };
|
||||
}
|
||||
|
||||
function modelChange(id: string, parentId: string | null, provider: string, modelId: string): ModelChangeEntry {
|
||||
return { type: "model_change", id, parentId, timestamp: "2025-01-01T00:00:00Z", provider, modelId };
|
||||
}
|
||||
|
||||
describe("buildSessionContext", () => {
|
||||
describe("trivial cases", () => {
|
||||
it("empty entries returns empty context", () => {
|
||||
const ctx = buildSessionContext([]);
|
||||
expect(ctx.messages).toEqual([]);
|
||||
expect(ctx.thinkingLevel).toBe("off");
|
||||
expect(ctx.model).toBeNull();
|
||||
});
|
||||
|
||||
it("single user message", () => {
|
||||
const entries: SessionEntry[] = [msg("1", null, "user", "hello")];
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.messages).toHaveLength(1);
|
||||
expect(ctx.messages[0].role).toBe("user");
|
||||
});
|
||||
|
||||
it("simple conversation", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "hello"),
|
||||
msg("2", "1", "assistant", "hi there"),
|
||||
msg("3", "2", "user", "how are you"),
|
||||
msg("4", "3", "assistant", "great"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.messages).toHaveLength(4);
|
||||
expect(ctx.messages.map((m) => m.role)).toEqual(["user", "assistant", "user", "assistant"]);
|
||||
});
|
||||
|
||||
it("tracks thinking level changes", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "hello"),
|
||||
thinkingLevel("2", "1", "high"),
|
||||
msg("3", "2", "assistant", "thinking hard"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.thinkingLevel).toBe("high");
|
||||
expect(ctx.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("tracks model from assistant message", () => {
|
||||
const entries: SessionEntry[] = [msg("1", null, "user", "hello"), msg("2", "1", "assistant", "hi")];
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.model).toEqual({ provider: "anthropic", modelId: "claude-test" });
|
||||
});
|
||||
|
||||
it("tracks model from model change entry", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "hello"),
|
||||
modelChange("2", "1", "openai", "gpt-4"),
|
||||
msg("3", "2", "assistant", "hi"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
// Assistant message overwrites model change
|
||||
expect(ctx.model).toEqual({ provider: "anthropic", modelId: "claude-test" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("with compaction", () => {
|
||||
it("includes summary before kept messages", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "first"),
|
||||
msg("2", "1", "assistant", "response1"),
|
||||
msg("3", "2", "user", "second"),
|
||||
msg("4", "3", "assistant", "response2"),
|
||||
compaction("5", "4", "Summary of first two turns", "3"),
|
||||
msg("6", "5", "user", "third"),
|
||||
msg("7", "6", "assistant", "response3"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
|
||||
// Should have: summary + kept (3,4) + after (6,7) = 5 messages
|
||||
expect(ctx.messages).toHaveLength(5);
|
||||
expect((ctx.messages[0] as any).summary).toContain("Summary of first two turns");
|
||||
expect((ctx.messages[1] as any).content).toBe("second");
|
||||
expect((ctx.messages[2] as any).content[0].text).toBe("response2");
|
||||
expect((ctx.messages[3] as any).content).toBe("third");
|
||||
expect((ctx.messages[4] as any).content[0].text).toBe("response3");
|
||||
});
|
||||
|
||||
it("handles compaction keeping from first message", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "first"),
|
||||
msg("2", "1", "assistant", "response"),
|
||||
compaction("3", "2", "Empty summary", "1"),
|
||||
msg("4", "3", "user", "second"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
|
||||
// Summary + all messages (1,2,4)
|
||||
expect(ctx.messages).toHaveLength(4);
|
||||
expect((ctx.messages[0] as any).summary).toContain("Empty summary");
|
||||
});
|
||||
|
||||
it("multiple compactions uses latest", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "a"),
|
||||
msg("2", "1", "assistant", "b"),
|
||||
compaction("3", "2", "First summary", "1"),
|
||||
msg("4", "3", "user", "c"),
|
||||
msg("5", "4", "assistant", "d"),
|
||||
compaction("6", "5", "Second summary", "4"),
|
||||
msg("7", "6", "user", "e"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries);
|
||||
|
||||
// Should use second summary, keep from 4
|
||||
expect(ctx.messages).toHaveLength(4);
|
||||
expect((ctx.messages[0] as any).summary).toContain("Second summary");
|
||||
});
|
||||
|
||||
it("buildContextEntries returns compaction-aware entries including custom entries", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "first"),
|
||||
custom("2", "1", "old-state", { hidden: true }),
|
||||
msg("3", "2", "assistant", "response1"),
|
||||
custom("4", "3", "kept-card", { title: "Kept" }),
|
||||
msg("5", "4", "user", "second"),
|
||||
compaction("6", "5", "Summary", "4"),
|
||||
custom("7", "6", "after-card", { title: "After" }),
|
||||
msg("8", "7", "assistant", "response2"),
|
||||
];
|
||||
|
||||
expect(buildContextEntries(entries).map((entry) => entry.id)).toEqual(["6", "4", "5", "7", "8"]);
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.messages.map((message) => message.role)).toEqual(["compactionSummary", "user", "assistant"]);
|
||||
});
|
||||
|
||||
it("keeps settings from the full path after compaction", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "first"),
|
||||
thinkingLevel("2", "1", "high"),
|
||||
msg("3", "2", "assistant", "response1"),
|
||||
msg("4", "3", "user", "second"),
|
||||
compaction("5", "4", "Summary", "4"),
|
||||
];
|
||||
|
||||
const ctx = buildSessionContext(entries);
|
||||
expect(ctx.thinkingLevel).toBe("high");
|
||||
expect(ctx.messages.map((message) => message.role)).toEqual(["compactionSummary", "user"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with branches", () => {
|
||||
it("follows path to specified leaf", () => {
|
||||
// Tree:
|
||||
// 1 -> 2 -> 3 (branch A)
|
||||
// \-> 4 (branch B)
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "start"),
|
||||
msg("2", "1", "assistant", "response"),
|
||||
msg("3", "2", "user", "branch A"),
|
||||
msg("4", "2", "user", "branch B"),
|
||||
];
|
||||
|
||||
const ctxA = buildSessionContext(entries, "3");
|
||||
expect(ctxA.messages).toHaveLength(3);
|
||||
expect((ctxA.messages[2] as any).content).toBe("branch A");
|
||||
|
||||
const ctxB = buildSessionContext(entries, "4");
|
||||
expect(ctxB.messages).toHaveLength(3);
|
||||
expect((ctxB.messages[2] as any).content).toBe("branch B");
|
||||
});
|
||||
|
||||
it("includes branch summary in path", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "start"),
|
||||
msg("2", "1", "assistant", "response"),
|
||||
msg("3", "2", "user", "abandoned path"),
|
||||
branchSummary("4", "2", "Summary of abandoned work", "3"),
|
||||
msg("5", "4", "user", "new direction"),
|
||||
];
|
||||
const ctx = buildSessionContext(entries, "5");
|
||||
|
||||
expect(ctx.messages).toHaveLength(4);
|
||||
expect((ctx.messages[2] as any).summary).toContain("Summary of abandoned work");
|
||||
expect((ctx.messages[3] as any).content).toBe("new direction");
|
||||
});
|
||||
|
||||
it("complex tree with multiple branches and compaction", () => {
|
||||
// Tree:
|
||||
// 1 -> 2 -> 3 -> 4 -> compaction(5) -> 6 -> 7 (main path)
|
||||
// \-> 8 -> 9 (abandoned branch)
|
||||
// \-> branchSummary(10) -> 11 (resumed from 3)
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "start"),
|
||||
msg("2", "1", "assistant", "r1"),
|
||||
msg("3", "2", "user", "q2"),
|
||||
msg("4", "3", "assistant", "r2"),
|
||||
compaction("5", "4", "Compacted history", "3"),
|
||||
msg("6", "5", "user", "q3"),
|
||||
msg("7", "6", "assistant", "r3"),
|
||||
// Abandoned branch from 3
|
||||
msg("8", "3", "user", "wrong path"),
|
||||
msg("9", "8", "assistant", "wrong response"),
|
||||
// Branch summary resuming from 3
|
||||
branchSummary("10", "3", "Tried wrong approach", "9"),
|
||||
msg("11", "10", "user", "better approach"),
|
||||
];
|
||||
|
||||
// Main path to 7: summary + kept(3,4) + after(6,7)
|
||||
const ctxMain = buildSessionContext(entries, "7");
|
||||
expect(ctxMain.messages).toHaveLength(5);
|
||||
expect((ctxMain.messages[0] as any).summary).toContain("Compacted history");
|
||||
expect((ctxMain.messages[1] as any).content).toBe("q2");
|
||||
expect((ctxMain.messages[2] as any).content[0].text).toBe("r2");
|
||||
expect((ctxMain.messages[3] as any).content).toBe("q3");
|
||||
expect((ctxMain.messages[4] as any).content[0].text).toBe("r3");
|
||||
|
||||
// Branch path to 11: 1,2,3 + branch_summary + 11
|
||||
const ctxBranch = buildSessionContext(entries, "11");
|
||||
expect(ctxBranch.messages).toHaveLength(5);
|
||||
expect((ctxBranch.messages[0] as any).content).toBe("start");
|
||||
expect((ctxBranch.messages[1] as any).content[0].text).toBe("r1");
|
||||
expect((ctxBranch.messages[2] as any).content).toBe("q2");
|
||||
expect((ctxBranch.messages[3] as any).summary).toContain("Tried wrong approach");
|
||||
expect((ctxBranch.messages[4] as any).content).toBe("better approach");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("uses last entry when leafId not found", () => {
|
||||
const entries: SessionEntry[] = [msg("1", null, "user", "hello"), msg("2", "1", "assistant", "hi")];
|
||||
const ctx = buildSessionContext(entries, "nonexistent");
|
||||
expect(ctx.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles orphaned entries gracefully", () => {
|
||||
const entries: SessionEntry[] = [
|
||||
msg("1", null, "user", "hello"),
|
||||
msg("2", "missing", "assistant", "orphan"), // parent doesn't exist
|
||||
];
|
||||
const ctx = buildSessionContext(entries, "2");
|
||||
// Should only get the orphan since parent chain is broken
|
||||
expect(ctx.messages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SessionManager } from "../../src/core/session-manager.ts";
|
||||
|
||||
const UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("SessionManager.newSession with custom id", () => {
|
||||
it("uses the provided id instead of generating one", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.newSession({ id: "my-custom-id" });
|
||||
expect(session.getSessionId()).toBe("my-custom-id");
|
||||
});
|
||||
|
||||
it("uses the provided id when creating an in-memory session", () => {
|
||||
const session = SessionManager.inMemory(process.cwd(), { id: "memory-session-id" });
|
||||
expect(session.getSessionId()).toBe("memory-session-id");
|
||||
expect(session.getHeader()!.id).toBe("memory-session-id");
|
||||
expect(session.getSessionFile()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("allows alphanumeric session ids with interior punctuation", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.newSession({ id: "abc-123_def.456" });
|
||||
expect(session.getSessionId()).toBe("abc-123_def.456");
|
||||
});
|
||||
|
||||
it("rejects invalid custom session ids", () => {
|
||||
const invalidIds = ["", "-abc", "abc-", "_abc", "abc_", ".abc", "abc.", "abc/def", "abc\\def", "abc def"];
|
||||
|
||||
for (const id of invalidIds) {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(() => session.newSession({ id })).toThrow(
|
||||
"Session id must be non-empty, contain only alphanumeric characters",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when no id is provided", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.newSession();
|
||||
const id = session.getSessionId();
|
||||
expect(id).toBeDefined();
|
||||
expect(id).not.toBe("");
|
||||
expect(id).toMatch(UUID_V7_RE);
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when options is provided without id", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.newSession({ parentSession: "parent.jsonl" });
|
||||
const id = session.getSessionId();
|
||||
expect(id).toBeDefined();
|
||||
expect(id).not.toBe("");
|
||||
expect(id).toMatch(UUID_V7_RE);
|
||||
});
|
||||
|
||||
it("includes the custom id in the session header", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.newSession({ id: "header-test-id" });
|
||||
|
||||
const header = session.getHeader();
|
||||
expect(header).not.toBeNull();
|
||||
expect(header!.id).toBe("header-test-id");
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when constructed without an explicit id", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getSessionId()).toMatch(UUID_V7_RE);
|
||||
expect(session.getHeader()!.id).toBe(session.getSessionId());
|
||||
});
|
||||
|
||||
it("uses the provided id when creating a persisted session", () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-"));
|
||||
const session = SessionManager.create(tempDir, tempDir, { id: "created-session-id" });
|
||||
|
||||
expect(session.getSessionId()).toBe("created-session-id");
|
||||
expect(session.getHeader()!.id).toBe("created-session-id");
|
||||
const sessionFile = session.getSessionFile()!;
|
||||
expect(sessionFile).toContain("created-session-id");
|
||||
expect(basename(sessionFile)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_created-session-id\.jsonl$/);
|
||||
expect(existsSync(sessionFile)).toBe(false);
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when creating a branched session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
const firstId = session.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
session.createBranchedSession(firstId);
|
||||
|
||||
expect(session.getSessionId()).toMatch(UUID_V7_RE);
|
||||
expect(session.getHeader()!.id).toBe(session.getSessionId());
|
||||
});
|
||||
|
||||
it("generates a UUIDv7 id when forking from another session file", () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-"));
|
||||
const sourcePath = join(tempDir, "source.jsonl");
|
||||
writeFileSync(
|
||||
sourcePath,
|
||||
`${[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "legacy-session-id",
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: tempDir,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "gpt-5.4",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}),
|
||||
].join("\n")}
|
||||
`,
|
||||
);
|
||||
|
||||
const forked = SessionManager.forkFrom(sourcePath, tempDir, tempDir);
|
||||
const header = forked.getHeader();
|
||||
expect(header).not.toBeNull();
|
||||
expect(header!.id).toMatch(UUID_V7_RE);
|
||||
expect(header!.parentSession).toBe(sourcePath);
|
||||
});
|
||||
|
||||
it("uses the provided id when forking from another session file", () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "pi-session-manager-"));
|
||||
const sourcePath = join(tempDir, "source.jsonl");
|
||||
writeFileSync(
|
||||
sourcePath,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "source-session-id",
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: tempDir,
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const forked = SessionManager.forkFrom(sourcePath, tempDir, tempDir, { id: "forked-session-id" });
|
||||
const header = forked.getHeader();
|
||||
expect(header).not.toBeNull();
|
||||
expect(header!.id).toBe("forked-session-id");
|
||||
expect(header!.parentSession).toBe(sourcePath);
|
||||
const sessionFile = forked.getSessionFile()!;
|
||||
expect(sessionFile).toContain("forked-session-id");
|
||||
expect(basename(sessionFile)).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_forked-session-id\.jsonl$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { constants as bufferConstants } from "buffer";
|
||||
import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { findMostRecentSession, loadEntriesFromFile, SessionManager } from "../../src/core/session-manager.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.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);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type LabelEntry, SessionManager } from "../../src/core/session-manager.ts";
|
||||
|
||||
describe("SessionManager labels", () => {
|
||||
it("sets and gets labels", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
|
||||
// No label initially
|
||||
expect(session.getLabel(msgId)).toBeUndefined();
|
||||
|
||||
// Set a label
|
||||
const labelId = session.appendLabelChange(msgId, "checkpoint");
|
||||
expect(session.getLabel(msgId)).toBe("checkpoint");
|
||||
|
||||
// Label entry should be in entries
|
||||
const entries = session.getEntries();
|
||||
const labelEntry = entries.find((e) => e.type === "label") as LabelEntry;
|
||||
expect(labelEntry).toBeDefined();
|
||||
expect(labelEntry.id).toBe(labelId);
|
||||
expect(labelEntry.targetId).toBe(msgId);
|
||||
expect(labelEntry.label).toBe("checkpoint");
|
||||
});
|
||||
|
||||
it("clears labels with undefined", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
|
||||
session.appendLabelChange(msgId, "checkpoint");
|
||||
expect(session.getLabel(msgId)).toBe("checkpoint");
|
||||
|
||||
// Clear the label
|
||||
session.appendLabelChange(msgId, undefined);
|
||||
expect(session.getLabel(msgId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("last label wins", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
|
||||
session.appendLabelChange(msgId, "first");
|
||||
session.appendLabelChange(msgId, "second");
|
||||
const lastLabelId = session.appendLabelChange(msgId, "third");
|
||||
|
||||
expect(session.getLabel(msgId)).toBe("third");
|
||||
|
||||
const entries = session.getEntries();
|
||||
const lastLabelEntry = entries.find((e) => e.id === lastLabelId) as LabelEntry;
|
||||
const tree = session.getTree();
|
||||
const msgNode = tree.find((n) => n.entry.id === msgId);
|
||||
expect(msgNode?.labelTimestamp).toBe(lastLabelEntry.timestamp);
|
||||
});
|
||||
|
||||
it("labels are included in tree nodes", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
const msg2Id = session.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
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: 2,
|
||||
});
|
||||
|
||||
const msg1LabelId = session.appendLabelChange(msg1Id, "start");
|
||||
const msg2LabelId = session.appendLabelChange(msg2Id, "response");
|
||||
|
||||
const entries = session.getEntries();
|
||||
const msg1LabelEntry = entries.find((e) => e.id === msg1LabelId) as LabelEntry;
|
||||
const msg2LabelEntry = entries.find((e) => e.id === msg2LabelId) as LabelEntry;
|
||||
const tree = session.getTree();
|
||||
|
||||
// Find the message nodes (skip label entries)
|
||||
const msg1Node = tree.find((n) => n.entry.id === msg1Id);
|
||||
expect(msg1Node?.label).toBe("start");
|
||||
expect(msg1Node?.labelTimestamp).toBe(msg1LabelEntry.timestamp);
|
||||
|
||||
// msg2 is a child of msg1
|
||||
const msg2Node = msg1Node?.children.find((n) => n.entry.id === msg2Id);
|
||||
expect(msg2Node?.label).toBe("response");
|
||||
expect(msg2Node?.labelTimestamp).toBe(msg2LabelEntry.timestamp);
|
||||
});
|
||||
|
||||
it("labels are preserved in createBranchedSession", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
const msg2Id = session.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
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: 2,
|
||||
});
|
||||
|
||||
const msg1LabelId = session.appendLabelChange(msg1Id, "important");
|
||||
const msg2LabelId = session.appendLabelChange(msg2Id, "also-important");
|
||||
const originalEntries = session.getEntries();
|
||||
const msg1LabelEntry = originalEntries.find((e) => e.id === msg1LabelId) as LabelEntry;
|
||||
const msg2LabelEntry = originalEntries.find((e) => e.id === msg2LabelId) as LabelEntry;
|
||||
|
||||
// Branch from msg2 (in-memory mode returns null, but updates internal state)
|
||||
session.createBranchedSession(msg2Id);
|
||||
|
||||
// Labels should be preserved
|
||||
expect(session.getLabel(msg1Id)).toBe("important");
|
||||
expect(session.getLabel(msg2Id)).toBe("also-important");
|
||||
|
||||
// New label entries should exist
|
||||
const entries = session.getEntries();
|
||||
const labelEntries = entries.filter((e) => e.type === "label") as LabelEntry[];
|
||||
expect(labelEntries).toHaveLength(2);
|
||||
|
||||
const tree = session.getTree();
|
||||
const msg1Node = tree.find((n) => n.entry.id === msg1Id);
|
||||
const msg2Node = msg1Node?.children.find((n) => n.entry.id === msg2Id);
|
||||
expect(msg1Node?.labelTimestamp).toBe(msg1LabelEntry.timestamp);
|
||||
expect(msg2Node?.labelTimestamp).toBe(msg2LabelEntry.timestamp);
|
||||
});
|
||||
|
||||
it("rewires children of removed labels when forking", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
session.appendLabelChange(msg1Id, "checkpoint");
|
||||
const modelChangeId = session.appendModelChange("anthropic", "claude-test");
|
||||
const msg2Id = session.appendMessage({ role: "user", content: "followup", timestamp: 2 });
|
||||
|
||||
session.createBranchedSession(msg2Id);
|
||||
|
||||
expect(session.getEntry(modelChangeId)?.parentId).toBe(msg1Id);
|
||||
});
|
||||
|
||||
it("labels not on path are not preserved in createBranchedSession", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msg1Id = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
const msg2Id = session.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
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: 2,
|
||||
});
|
||||
const msg3Id = session.appendMessage({ role: "user", content: "followup", timestamp: 3 });
|
||||
|
||||
// Label all messages
|
||||
session.appendLabelChange(msg1Id, "first");
|
||||
session.appendLabelChange(msg2Id, "second");
|
||||
session.appendLabelChange(msg3Id, "third");
|
||||
|
||||
// Branch from msg2 (excludes msg3)
|
||||
session.createBranchedSession(msg2Id);
|
||||
|
||||
// Only labels for msg1 and msg2 should be preserved
|
||||
expect(session.getLabel(msg1Id)).toBe("first");
|
||||
expect(session.getLabel(msg2Id)).toBe("second");
|
||||
expect(session.getLabel(msg3Id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("labels are not included in buildSessionContext", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
session.appendLabelChange(msgId, "checkpoint");
|
||||
|
||||
const ctx = session.buildSessionContext();
|
||||
expect(ctx.messages).toHaveLength(1);
|
||||
expect(ctx.messages[0].role).toBe("user");
|
||||
});
|
||||
|
||||
it("throws when labeling non-existent entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
expect(() => session.appendLabelChange("non-existent", "label")).toThrow("Entry non-existent not found");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type FileEntry, migrateSessionEntries } from "../../src/core/session-manager.ts";
|
||||
|
||||
describe("migrateSessionEntries", () => {
|
||||
it("should add id/parentId to v1 entries", () => {
|
||||
const entries: FileEntry[] = [
|
||||
{ type: "session", id: "sess-1", timestamp: "2025-01-01T00:00:00Z", cwd: "/tmp" },
|
||||
{ type: "message", timestamp: "2025-01-01T00:00:01Z", message: { role: "user", content: "hi", timestamp: 1 } },
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2025-01-01T00:00:02Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
api: "test",
|
||||
provider: "test",
|
||||
model: "test",
|
||||
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
] as FileEntry[];
|
||||
|
||||
migrateSessionEntries(entries);
|
||||
|
||||
// Header should have version set (v3 is current after hookMessage->custom migration)
|
||||
expect((entries[0] as any).version).toBe(3);
|
||||
|
||||
// Entries should have id/parentId
|
||||
const msg1 = entries[1] as any;
|
||||
const msg2 = entries[2] as any;
|
||||
|
||||
expect(msg1.id).toBeDefined();
|
||||
expect(msg1.id.length).toBe(8);
|
||||
expect(msg1.parentId).toBeNull();
|
||||
|
||||
expect(msg2.id).toBeDefined();
|
||||
expect(msg2.id.length).toBe(8);
|
||||
expect(msg2.parentId).toBe(msg1.id);
|
||||
});
|
||||
|
||||
it("should be idempotent (skip already migrated)", () => {
|
||||
const entries: FileEntry[] = [
|
||||
{ type: "session", id: "sess-1", version: 2, timestamp: "2025-01-01T00:00:00Z", cwd: "/tmp" },
|
||||
{
|
||||
type: "message",
|
||||
id: "abc12345",
|
||||
parentId: null,
|
||||
timestamp: "2025-01-01T00:00:01Z",
|
||||
message: { role: "user", content: "hi", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "def67890",
|
||||
parentId: "abc12345",
|
||||
timestamp: "2025-01-01T00:00:02Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
api: "test",
|
||||
provider: "test",
|
||||
model: "test",
|
||||
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
] as FileEntry[];
|
||||
|
||||
migrateSessionEntries(entries);
|
||||
|
||||
// IDs should be unchanged
|
||||
expect((entries[1] as any).id).toBe("abc12345");
|
||||
expect((entries[2] as any).id).toBe("def67890");
|
||||
expect((entries[2] as any).parentId).toBe("abc12345");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type CustomEntry, SessionManager } from "../../src/core/session-manager.ts";
|
||||
|
||||
describe("SessionManager.saveCustomEntry", () => {
|
||||
it("saves custom entries and includes them in tree traversal", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Save a message
|
||||
const msgId = session.appendMessage({ role: "user", content: "hello", timestamp: 1 });
|
||||
|
||||
// Save a custom entry
|
||||
const customId = session.appendCustomEntry("my_data", { foo: "bar" });
|
||||
|
||||
// Save another message
|
||||
const msg2Id = session.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "hi" }],
|
||||
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: 2,
|
||||
});
|
||||
|
||||
// Custom entry should be in entries
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(3);
|
||||
|
||||
const customEntry = entries.find((e) => e.type === "custom") as CustomEntry;
|
||||
expect(customEntry).toBeDefined();
|
||||
expect(customEntry.customType).toBe("my_data");
|
||||
expect(customEntry.data).toEqual({ foo: "bar" });
|
||||
expect(customEntry.id).toBe(customId);
|
||||
expect(customEntry.parentId).toBe(msgId);
|
||||
|
||||
// Tree structure should be correct
|
||||
const path = session.getBranch();
|
||||
expect(path).toHaveLength(3);
|
||||
expect(path[0].id).toBe(msgId);
|
||||
expect(path[1].id).toBe(customId);
|
||||
expect(path[2].id).toBe(msg2Id);
|
||||
|
||||
// buildSessionContext should work (custom entries skipped in messages)
|
||||
const ctx = session.buildSessionContext();
|
||||
expect(ctx.messages).toHaveLength(2); // only message entries
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,597 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type CustomEntry, SessionManager } from "../../src/core/session-manager.ts";
|
||||
import { assistantMsg, userMsg } from "../utilities.ts";
|
||||
|
||||
describe("SessionManager append and tree traversal", () => {
|
||||
describe("append operations", () => {
|
||||
it("appendMessage creates entry with correct parentId chain", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("first"));
|
||||
const id2 = session.appendMessage(assistantMsg("second"));
|
||||
const id3 = session.appendMessage(userMsg("third"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(3);
|
||||
|
||||
expect(entries[0].id).toBe(id1);
|
||||
expect(entries[0].parentId).toBeNull();
|
||||
expect(entries[0].type).toBe("message");
|
||||
|
||||
expect(entries[1].id).toBe(id2);
|
||||
expect(entries[1].parentId).toBe(id1);
|
||||
|
||||
expect(entries[2].id).toBe(id3);
|
||||
expect(entries[2].parentId).toBe(id2);
|
||||
});
|
||||
|
||||
it("appendThinkingLevelChange integrates into tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage(userMsg("hello"));
|
||||
const thinkingId = session.appendThinkingLevelChange("high");
|
||||
const _msg2Id = session.appendMessage(assistantMsg("response"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(3);
|
||||
|
||||
const thinkingEntry = entries.find((e) => e.type === "thinking_level_change");
|
||||
expect(thinkingEntry).toBeDefined();
|
||||
expect(thinkingEntry!.id).toBe(thinkingId);
|
||||
expect(thinkingEntry!.parentId).toBe(msgId);
|
||||
|
||||
expect(entries[2].parentId).toBe(thinkingId);
|
||||
});
|
||||
|
||||
it("appendModelChange integrates into tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage(userMsg("hello"));
|
||||
const modelId = session.appendModelChange("openai", "gpt-4");
|
||||
const _msg2Id = session.appendMessage(assistantMsg("response"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
const modelEntry = entries.find((e) => e.type === "model_change");
|
||||
expect(modelEntry).toBeDefined();
|
||||
expect(modelEntry?.id).toBe(modelId);
|
||||
expect(modelEntry?.parentId).toBe(msgId);
|
||||
if (modelEntry?.type === "model_change") {
|
||||
expect(modelEntry.provider).toBe("openai");
|
||||
expect(modelEntry.modelId).toBe("gpt-4");
|
||||
}
|
||||
|
||||
expect(entries[2].parentId).toBe(modelId);
|
||||
});
|
||||
|
||||
it("appendCompaction integrates into tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const usage = {
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 30,
|
||||
cacheWrite: 40,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
};
|
||||
const compactionId = session.appendCompaction("summary", id1, 1000, undefined, false, usage);
|
||||
const _id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
const compactionEntry = entries.find((e) => e.type === "compaction");
|
||||
expect(compactionEntry).toBeDefined();
|
||||
expect(compactionEntry?.id).toBe(compactionId);
|
||||
expect(compactionEntry?.parentId).toBe(id2);
|
||||
if (compactionEntry?.type === "compaction") {
|
||||
expect(compactionEntry.summary).toBe("summary");
|
||||
expect(compactionEntry.firstKeptEntryId).toBe(id1);
|
||||
expect(compactionEntry.tokensBefore).toBe(1000);
|
||||
expect(compactionEntry.usage).toEqual(usage);
|
||||
}
|
||||
|
||||
expect(entries[3].parentId).toBe(compactionId);
|
||||
});
|
||||
|
||||
it("appendCustomEntry integrates into tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const msgId = session.appendMessage(userMsg("hello"));
|
||||
const customId = session.appendCustomEntry("my_data", { key: "value" });
|
||||
const _msg2Id = session.appendMessage(assistantMsg("response"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
const customEntry = entries.find((e) => e.type === "custom") as CustomEntry;
|
||||
expect(customEntry).toBeDefined();
|
||||
expect(customEntry.id).toBe(customId);
|
||||
expect(customEntry.parentId).toBe(msgId);
|
||||
expect(customEntry.customType).toBe("my_data");
|
||||
expect(customEntry.data).toEqual({ key: "value" });
|
||||
|
||||
expect(entries[2].parentId).toBe(customId);
|
||||
});
|
||||
|
||||
it("leaf pointer advances after each append", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
expect(session.getLeafId()).toBeNull();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
expect(session.getLeafId()).toBe(id1);
|
||||
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
expect(session.getLeafId()).toBe(id2);
|
||||
|
||||
const id3 = session.appendThinkingLevelChange("high");
|
||||
expect(session.getLeafId()).toBe(id3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPath", () => {
|
||||
it("returns empty array for empty session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getBranch()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single entry path", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
const id = session.appendMessage(userMsg("hello"));
|
||||
|
||||
const path = session.getBranch();
|
||||
expect(path).toHaveLength(1);
|
||||
expect(path[0].id).toBe(id);
|
||||
});
|
||||
|
||||
it("returns full path from root to leaf", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendThinkingLevelChange("high");
|
||||
const id4 = session.appendMessage(userMsg("3"));
|
||||
|
||||
const path = session.getBranch();
|
||||
expect(path).toHaveLength(4);
|
||||
expect(path.map((e) => e.id)).toEqual([id1, id2, id3, id4]);
|
||||
});
|
||||
|
||||
it("returns path from specified entry to root", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const _id3 = session.appendMessage(userMsg("3"));
|
||||
const _id4 = session.appendMessage(assistantMsg("4"));
|
||||
|
||||
const path = session.getBranch(id2);
|
||||
expect(path).toHaveLength(2);
|
||||
expect(path.map((e) => e.id)).toEqual([id1, id2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTree", () => {
|
||||
it("returns empty array for empty session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getTree()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns single root for linear session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
const tree = session.getTree();
|
||||
expect(tree).toHaveLength(1);
|
||||
|
||||
const root = tree[0];
|
||||
expect(root.entry.id).toBe(id1);
|
||||
expect(root.children).toHaveLength(1);
|
||||
expect(root.children[0].entry.id).toBe(id2);
|
||||
expect(root.children[0].children).toHaveLength(1);
|
||||
expect(root.children[0].children[0].entry.id).toBe(id3);
|
||||
expect(root.children[0].children[0].children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns tree with branches after branch", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Build: 1 -> 2 -> 3
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
// Branch from id2, add new path: 2 -> 4
|
||||
session.branch(id2);
|
||||
const id4 = session.appendMessage(userMsg("4-branch"));
|
||||
|
||||
const tree = session.getTree();
|
||||
expect(tree).toHaveLength(1);
|
||||
|
||||
const root = tree[0];
|
||||
expect(root.entry.id).toBe(id1);
|
||||
expect(root.children).toHaveLength(1);
|
||||
|
||||
const node2 = root.children[0];
|
||||
expect(node2.entry.id).toBe(id2);
|
||||
expect(node2.children).toHaveLength(2); // id3 and id4 are siblings
|
||||
|
||||
const childIds = node2.children.map((c) => c.entry.id).sort();
|
||||
expect(childIds).toEqual([id3, id4].sort());
|
||||
});
|
||||
|
||||
it("handles multiple branches at same point", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const _id1 = session.appendMessage(userMsg("root"));
|
||||
const id2 = session.appendMessage(assistantMsg("response"));
|
||||
|
||||
// Branch A
|
||||
session.branch(id2);
|
||||
const idA = session.appendMessage(userMsg("branch-A"));
|
||||
|
||||
// Branch B
|
||||
session.branch(id2);
|
||||
const idB = session.appendMessage(userMsg("branch-B"));
|
||||
|
||||
// Branch C
|
||||
session.branch(id2);
|
||||
const idC = session.appendMessage(userMsg("branch-C"));
|
||||
|
||||
const tree = session.getTree();
|
||||
const node2 = tree[0].children[0];
|
||||
expect(node2.entry.id).toBe(id2);
|
||||
expect(node2.children).toHaveLength(3);
|
||||
|
||||
const branchIds = node2.children.map((c) => c.entry.id).sort();
|
||||
expect(branchIds).toEqual([idA, idB, idC].sort());
|
||||
});
|
||||
|
||||
it("handles deep branching", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Main path: 1 -> 2 -> 3 -> 4
|
||||
const _id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
const _id4 = session.appendMessage(assistantMsg("4"));
|
||||
|
||||
// Branch from 2: 2 -> 5 -> 6
|
||||
session.branch(id2);
|
||||
const id5 = session.appendMessage(userMsg("5"));
|
||||
const _id6 = session.appendMessage(assistantMsg("6"));
|
||||
|
||||
// Branch from 5: 5 -> 7
|
||||
session.branch(id5);
|
||||
const _id7 = session.appendMessage(userMsg("7"));
|
||||
|
||||
const tree = session.getTree();
|
||||
|
||||
// Verify structure
|
||||
const node2 = tree[0].children[0];
|
||||
expect(node2.children).toHaveLength(2); // id3 and id5
|
||||
|
||||
const node5 = node2.children.find((c) => c.entry.id === id5)!;
|
||||
expect(node5.children).toHaveLength(2); // id6 and id7
|
||||
|
||||
const node3 = node2.children.find((c) => c.entry.id === id3)!;
|
||||
expect(node3.children).toHaveLength(1); // id4
|
||||
});
|
||||
});
|
||||
|
||||
describe("branch", () => {
|
||||
it("moves leaf pointer to specified entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const _id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
expect(session.getLeafId()).toBe(id3);
|
||||
|
||||
session.branch(id1);
|
||||
expect(session.getLeafId()).toBe(id1);
|
||||
});
|
||||
|
||||
it("throws for non-existent entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.appendMessage(userMsg("hello"));
|
||||
|
||||
expect(() => session.branch("nonexistent")).toThrow("Entry nonexistent not found");
|
||||
});
|
||||
|
||||
it("new appends become children of branch point", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const _id2 = session.appendMessage(assistantMsg("2"));
|
||||
|
||||
session.branch(id1);
|
||||
const id3 = session.appendMessage(userMsg("branched"));
|
||||
|
||||
const entries = session.getEntries();
|
||||
const branchedEntry = entries.find((e) => e.id === id3)!;
|
||||
expect(branchedEntry.parentId).toBe(id1); // sibling of id2
|
||||
});
|
||||
});
|
||||
|
||||
describe("branchWithSummary", () => {
|
||||
it("inserts branch summary and advances leaf", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const _id2 = session.appendMessage(assistantMsg("2"));
|
||||
const _id3 = session.appendMessage(userMsg("3"));
|
||||
|
||||
const usage = {
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 30,
|
||||
cacheWrite: 40,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
};
|
||||
const summaryId = session.branchWithSummary(id1, "Summary of abandoned work", undefined, false, usage);
|
||||
|
||||
expect(session.getLeafId()).toBe(summaryId);
|
||||
|
||||
const entries = session.getEntries();
|
||||
const summaryEntry = entries.find((e) => e.type === "branch_summary");
|
||||
expect(summaryEntry).toBeDefined();
|
||||
expect(summaryEntry?.parentId).toBe(id1);
|
||||
if (summaryEntry?.type === "branch_summary") {
|
||||
expect(summaryEntry.summary).toBe("Summary of abandoned work");
|
||||
expect(summaryEntry.usage).toEqual(usage);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws for non-existent entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.appendMessage(userMsg("hello"));
|
||||
|
||||
expect(() => session.branchWithSummary("nonexistent", "summary")).toThrow("Entry nonexistent not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLeafEntry", () => {
|
||||
it("returns undefined for empty session", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getLeafEntry()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns current leaf entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
|
||||
const leaf = session.getLeafEntry();
|
||||
expect(leaf).toBeDefined();
|
||||
expect(leaf!.id).toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEntry", () => {
|
||||
it("returns undefined for non-existent id", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
expect(session.getEntry("nonexistent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns entry by id", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
const id1 = session.appendMessage(userMsg("first"));
|
||||
const id2 = session.appendMessage(assistantMsg("second"));
|
||||
|
||||
const entry1 = session.getEntry(id1);
|
||||
expect(entry1).toBeDefined();
|
||||
expect(entry1?.type).toBe("message");
|
||||
if (entry1?.type === "message" && entry1.message.role === "user") {
|
||||
expect(entry1.message.content).toBe("first");
|
||||
}
|
||||
|
||||
const entry2 = session.getEntry(id2);
|
||||
expect(entry2).toBeDefined();
|
||||
if (entry2?.type === "message" && entry2.message.role === "assistant") {
|
||||
expect((entry2.message.content as any)[0].text).toBe("second");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionContext with branches", () => {
|
||||
it("returns messages from current branch only", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Main: 1 -> 2 -> 3
|
||||
session.appendMessage(userMsg("msg1"));
|
||||
const id2 = session.appendMessage(assistantMsg("msg2"));
|
||||
session.appendMessage(userMsg("msg3"));
|
||||
|
||||
// Branch from 2: 2 -> 4
|
||||
session.branch(id2);
|
||||
session.appendMessage(assistantMsg("msg4-branch"));
|
||||
|
||||
const ctx = session.buildSessionContext();
|
||||
expect(ctx.messages).toHaveLength(3); // msg1, msg2, msg4-branch (not msg3)
|
||||
|
||||
expect((ctx.messages[0] as any).content).toBe("msg1");
|
||||
expect((ctx.messages[1] as any).content[0].text).toBe("msg2");
|
||||
expect((ctx.messages[2] as any).content[0].text).toBe("msg4-branch");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBranchedSession", () => {
|
||||
it("throws for non-existent entry", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
session.appendMessage(userMsg("hello"));
|
||||
|
||||
expect(() => session.createBranchedSession("nonexistent")).toThrow("Entry nonexistent not found");
|
||||
});
|
||||
|
||||
it("creates new session with path to specified leaf (in-memory)", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Build: 1 -> 2 -> 3 -> 4
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
const id3 = session.appendMessage(userMsg("3"));
|
||||
session.appendMessage(assistantMsg("4"));
|
||||
|
||||
// Branch from 3: 3 -> 5
|
||||
session.branch(id3);
|
||||
const _id5 = session.appendMessage(userMsg("5"));
|
||||
|
||||
// Create branched session from id2 (should only have 1 -> 2)
|
||||
const result = session.createBranchedSession(id2);
|
||||
expect(result).toBeUndefined(); // in-memory returns null
|
||||
|
||||
// Session should now only have entries 1 and 2
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].id).toBe(id1);
|
||||
expect(entries[1].id).toBe(id2);
|
||||
});
|
||||
|
||||
it("extracts correct path from branched tree", () => {
|
||||
const session = SessionManager.inMemory();
|
||||
|
||||
// Build: 1 -> 2 -> 3
|
||||
const id1 = session.appendMessage(userMsg("1"));
|
||||
const id2 = session.appendMessage(assistantMsg("2"));
|
||||
session.appendMessage(userMsg("3"));
|
||||
|
||||
// Branch from 2: 2 -> 4 -> 5
|
||||
session.branch(id2);
|
||||
const id4 = session.appendMessage(userMsg("4"));
|
||||
const id5 = session.appendMessage(assistantMsg("5"));
|
||||
|
||||
// Create branched session from id5 (should have 1 -> 2 -> 4 -> 5)
|
||||
session.createBranchedSession(id5);
|
||||
|
||||
const entries = session.getEntries();
|
||||
expect(entries).toHaveLength(4);
|
||||
expect(entries.map((e) => e.id)).toEqual([id1, id2, id4, id5]);
|
||||
});
|
||||
|
||||
it("does not duplicate entries when forking from first user message", () => {
|
||||
const tempDir = join(tmpdir(), `session-fork-dedup-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
// Create a persisted session with a couple of turns
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
const id1 = session.appendMessage(userMsg("first question"));
|
||||
session.appendMessage(assistantMsg("first answer"));
|
||||
session.appendMessage(userMsg("second question"));
|
||||
session.appendMessage(assistantMsg("second answer"));
|
||||
|
||||
// Fork from the very first user message (no assistant in the branched path)
|
||||
const newFile = session.createBranchedSession(id1);
|
||||
expect(newFile).toBeDefined();
|
||||
|
||||
// The branched path has no assistant, so the file should not exist yet
|
||||
// (deferred to _persist on first assistant, matching newSession() contract)
|
||||
expect(existsSync(newFile!)).toBe(false);
|
||||
|
||||
// Simulate extension adding entry before assistant (like preset on turn_start)
|
||||
session.appendCustomEntry("preset-state", { name: "plan" });
|
||||
|
||||
// Now the assistant responds
|
||||
session.appendMessage(assistantMsg("new answer"));
|
||||
|
||||
// File should now exist with exactly one header and no duplicate IDs
|
||||
expect(existsSync(newFile!)).toBe(true);
|
||||
const content = readFileSync(newFile!, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const records = lines.map((line) => JSON.parse(line));
|
||||
|
||||
expect(records.filter((r) => r.type === "session")).toHaveLength(1);
|
||||
|
||||
const entryIds = records
|
||||
.filter((r) => r.type !== "session")
|
||||
.map((r) => r.id)
|
||||
.filter((id): id is string => typeof id === "string");
|
||||
expect(new Set(entryIds).size).toBe(entryIds.length);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves tool and summary usage across a file-backed reload", () => {
|
||||
const tempDir = join(tmpdir(), `session-usage-roundtrip-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
const rootId = session.appendMessage(userMsg("question"));
|
||||
session.appendMessage(assistantMsg("answer"));
|
||||
const usage = {
|
||||
input: 10,
|
||||
output: 20,
|
||||
cacheRead: 30,
|
||||
cacheWrite: 40,
|
||||
totalTokens: 100,
|
||||
cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 },
|
||||
};
|
||||
session.appendMessage({
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "nested-model",
|
||||
content: [{ type: "text", text: "result" }],
|
||||
isError: false,
|
||||
usage,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
session.appendCompaction("summary", rootId, 100, undefined, false, usage);
|
||||
session.branchWithSummary(rootId, "branch summary", undefined, false, usage);
|
||||
|
||||
const file = session.getSessionFile();
|
||||
expect(file).toBeDefined();
|
||||
const reopened = SessionManager.open(file!, tempDir);
|
||||
expect(reopened.getEntries()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "compaction", usage }),
|
||||
expect.objectContaining({ type: "branch_summary", usage }),
|
||||
expect.objectContaining({
|
||||
type: "message",
|
||||
message: expect.objectContaining({ role: "toolResult", usage }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("writes file immediately when forking from a point with assistant messages", () => {
|
||||
const tempDir = join(tmpdir(), `session-fork-with-assistant-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const session = SessionManager.create(tempDir, tempDir);
|
||||
session.appendMessage(userMsg("first question"));
|
||||
const id2 = session.appendMessage(assistantMsg("first answer"));
|
||||
session.appendMessage(userMsg("second question"));
|
||||
session.appendMessage(assistantMsg("second answer"));
|
||||
|
||||
// Fork including the assistant message
|
||||
const newFile = session.createBranchedSession(id2);
|
||||
expect(newFile).toBeDefined();
|
||||
|
||||
// Path includes an assistant, so file should be written immediately
|
||||
expect(existsSync(newFile!)).toBe(true);
|
||||
const content = readFileSync(newFile!, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
const records = lines.map((line) => JSON.parse(line));
|
||||
expect(records.filter((r) => r.type === "session")).toHaveLength(1);
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user