Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
15 kB · 385 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386import { appendFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";import { tmpdir } from "node:os";import { join } from "node:path";import { describe, expect, it } from "vitest";import { buildSessionContext, loadEntriesFromFile, SessionManager, type SessionStateEntry,} from "../../src/core/session-manager.js";import { inactiveLifecycleForSession } from "../../src/modes/daemon/daemon-session-list.js";import { assistantMsg, userMsg } from "../utilities.js";
describe("SessionManager session state", () => { it("persists lifecycle state and exposes it through list", async () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir);
session.appendMessage(userMsg("hello")); session.appendMessage(assistantMsg("hi")); session.appendSessionState({ status: "crash" });
const sessionFile = session.getSessionFile(); expect(sessionFile).toBeDefined(); expect(existsSync(sessionFile!)).toBe(true);
const stateEntries = loadEntriesFromFile(sessionFile!).filter( (entry): entry is SessionStateEntry => entry.type === "session_state", ); expect(stateEntries).toHaveLength(1); expect(stateEntries[0]!.state).toEqual({ status: "crash" }); expect(session.getSessionState()).toEqual({ status: "crash" });
const sessions = await SessionManager.list(cwd, sessionDir); expect(sessions).toHaveLength(1); expect(sessions[0]).toMatchObject({ id: session.getSessionId(), state: { status: "crash" }, }); } finally { rmSync(tempDir, { recursive: true, force: true }); } });
it("flushes lifecycle state for sessions without assistant messages", async () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-empty-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir);
session.appendSessionInfo("empty"); session.appendSessionState({ status: "archived" });
const sessionFile = session.getSessionFile(); expect(sessionFile).toBeDefined(); expect(existsSync(sessionFile!)).toBe(true);
const sessions = await SessionManager.list(cwd, sessionDir); expect(sessions).toHaveLength(1); expect(sessions[0]).toMatchObject({ id: session.getSessionId(), name: "empty", messageCount: 0, state: { status: "archived" }, }); } finally { rmSync(tempDir, { recursive: true, force: true }); } });
it("persists renamed sessions without assistant messages", async () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-rename-empty-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir); session.appendSessionState({ status: "active" }); const sessionFile = session.getSessionFile(); expect(sessionFile).toBeDefined();
SessionManager.open(sessionFile!, sessionDir).appendSessionInfo("Renamed draft");
await expect(SessionManager.list(cwd, sessionDir)).resolves.toEqual([ expect.objectContaining({ id: session.getSessionId(), name: "Renamed draft" }), ]); } finally { rmSync(tempDir, { recursive: true, force: true }); } });
it("archives a deactivated session that has no prior state entry", async () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-deactivate-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir); session.appendMessage(userMsg("hello")); session.appendMessage(assistantMsg("hi")); const sessionFile = session.getSessionFile()!;
const reopened = SessionManager.open(sessionFile, sessionDir); expect(reopened.getSessionState()).toBeUndefined(); if (reopened.getSessionState()?.status !== "archived") { reopened.appendSessionState({ status: "archived" }); }
const sessions = await SessionManager.list(cwd, sessionDir); expect(sessions).toHaveLength(1); expect(sessions[0]!.state).toEqual({ status: "archived" }); expect(inactiveLifecycleForSession(sessions[0]!)).toBe("archived"); } finally { rmSync(tempDir, { recursive: true, force: true }); } });
// Guards the agents-view deactivate path: opening a deleted file and appending // would recreate a stub session at the old path, so the caller must skip it. it("recreates a stub when archiving a deleted file, which the existsSync guard prevents", async () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-deleted-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir); session.appendMessage(userMsg("hello")); session.appendMessage(assistantMsg("hi")); // forces a flush to disk const sessionFile = session.getSessionFile()!; rmSync(sessionFile); expect(existsSync(sessionFile)).toBe(false);
// Without the guard, the open+append recreates a fresh stub on disk. SessionManager.open(sessionFile, sessionDir).appendSessionState({ status: "archived" }); expect(existsSync(sessionFile)).toBe(true);
// The guard the caller uses skips a missing file, leaving nothing behind. rmSync(sessionFile); if (existsSync(sessionFile)) { SessionManager.open(sessionFile, sessionDir).appendSessionState({ status: "archived" }); } expect(existsSync(sessionFile)).toBe(false); } finally { rmSync(tempDir, { recursive: true, force: true }); } });
it("coerces legacy sleep and hidden lifecycle state to archived on read", async () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-hidden-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir);
session.appendMessage(userMsg("hide me")); // Flush a header + state entry, then append the legacy raw "sleep"/"hidden" // entries older daemons wrote; both must normalize to "archived" on read. session.appendSessionState({ status: "active" }); const sessionFile = session.getSessionFile(); expect(sessionFile).toBeDefined(); appendFileSync(sessionFile!, `${JSON.stringify({ type: "session_state", state: { status: "sleep" } })}\n`); appendFileSync(sessionFile!, `${JSON.stringify({ type: "session_state", state: { status: "hidden" } })}\n`);
expect(SessionManager.open(sessionFile!, sessionDir).getSessionState()).toEqual({ status: "archived" });
const sessions = await SessionManager.list(cwd, sessionDir); expect(sessions).toHaveLength(1); expect(sessions[0]).toMatchObject({ id: session.getSessionId(), state: { status: "archived" }, }); } finally { rmSync(tempDir, { recursive: true, force: true }); } });
it("falls back to the last valid status when the newest entry is unrecognized", async () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-bad-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir);
session.appendMessage(userMsg("hi")); session.appendSessionState({ status: "active" }); const sessionFile = session.getSessionFile(); expect(sessionFile).toBeDefined(); appendFileSync(sessionFile!, `${JSON.stringify({ type: "session_state", state: { status: "bogus" } })}\n`);
expect(SessionManager.open(sessionFile!, sessionDir).getSessionState()).toEqual({ status: "active" });
const sessions = await SessionManager.list(cwd, sessionDir); expect(sessions[0]).toMatchObject({ id: session.getSessionId(), state: { status: "active" } }); } finally { rmSync(tempDir, { recursive: true, force: true }); } });
it("does not duplicate entries when lifecycle state is followed by a normal turn", async () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-turn-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir);
session.appendSessionState({ status: "archived" }); session.appendMessage(userMsg("hello")); session.appendMessage(assistantMsg("hi"));
const sessionFile = session.getSessionFile(); expect(sessionFile).toBeDefined();
const entries = loadEntriesFromFile(sessionFile!); expect(entries.filter((entry) => entry.type === "session_state")).toHaveLength(1); expect(entries.filter((entry) => entry.type === "message")).toHaveLength(2); } finally { rmSync(tempDir, { recursive: true, force: true }); } });
it("recreates the session directory when lifecycle state is the first persisted entry", () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-missing-dir-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir); const sessionFile = session.getSessionFile(); expect(sessionFile).toBeDefined();
rmSync(sessionDir, { recursive: true, force: true }); session.appendSessionState({ status: "archived" });
expect(existsSync(sessionFile!)).toBe(true); const entries = loadEntriesFromFile(sessionFile!); expect(entries[0]).toMatchObject({ type: "session", id: session.getSessionId() }); expect(entries.filter((entry) => entry.type === "session_state")).toHaveLength(1); } finally { rmSync(tempDir, { recursive: true, force: true }); } });
it("rewrites the full session if the session file disappears after flushing", () => { const tempDir = mkdtempSync(join(tmpdir(), "session-state-missing-file-")); try { const cwd = join(tempDir, "project"); const sessionDir = join(tempDir, "sessions"); const session = SessionManager.create(cwd, sessionDir);
session.appendMessage(userMsg("hello")); session.appendMessage(assistantMsg("hi")); const sessionFile = session.getSessionFile(); expect(sessionFile).toBeDefined(); expect(existsSync(sessionFile!)).toBe(true);
rmSync(sessionFile!, { force: true }); session.appendSessionState({ status: "archived" });
const entries = loadEntriesFromFile(sessionFile!); expect(entries[0]).toMatchObject({ type: "session", id: session.getSessionId() }); expect(entries.filter((entry) => entry.type === "message")).toHaveLength(2); expect(entries.filter((entry) => entry.type === "session_state")).toHaveLength(1); } finally { rmSync(tempDir, { recursive: true, force: true }); } });});describe("SessionManager agent status", () => { it("persists the latest agent status append-only, per branch, and keeps it out of model context", () => { const tempDir = mkdtempSync(join(tmpdir(), "agent-status-")); try { const session = SessionManager.create(join(tempDir, "project"), join(tempDir, "sessions"));
const m1 = session.appendMessage(userMsg("add a login endpoint")); const m2 = session.appendMessage(assistantMsg("done")); session.appendAgentStatus({ summary: "Working", taskState: undefined, basedOnMessageCount: 2 }); session.appendAgentStatus({ summary: "Added login endpoint", taskState: "completed", basedOnMessageCount: 2 });
// Latest entry wins. expect(session.getLatestAgentStatus()).toEqual({ summary: "Added login endpoint", taskState: "completed", basedOnMessageCount: 2, });
// Append-only: re-reading the raw file recovers both status entries and // the two conversation messages untouched. const entries = loadEntriesFromFile(session.getSessionFile()!); expect(entries.filter((entry) => entry.type === "agent_status")).toHaveLength(2); expect(entries.filter((entry) => entry.type === "message")).toHaveLength(2);
// Status never reaches the model. const context = buildSessionContext(session.getEntries(), session.getLeafId()); expect(context.messages).toHaveLength(2); expect(context.messages.every((message) => message.role === "user" || message.role === "assistant")).toBe( true, );
// Branch A off m1, then branch B off m2 with a later status in the file. session.branch(m1); const branchAStatus = session.appendAgentStatus({ summary: "branch A", basedOnMessageCount: 1 }); session.branch(m2); session.appendAgentStatus({ summary: "branch B", basedOnMessageCount: 1 });
// Re-activating branch A reads its own status, not the later sibling entry. session.branch(branchAStatus); expect(session.getLatestAgentStatus()?.summary).toBe("branch A"); } finally { rmSync(tempDir, { recursive: true, force: true }); } });});
describe("SessionManager.hasUserContent", () => { // createAgentSession writes the model/thinking/tier defaults for every new // session, so only edits made after creation count as real user content. const defaults = (s: SessionManager): void => { s.appendModelChange("anthropic", "claude-opus-4-8"); s.appendThinkingLevelChange("off"); }; const cases: Array<{ name: string; setup: (session: SessionManager) => void; expected: boolean }> = [ { name: "creation defaults only", setup: (s) => { defaults(s); s.appendServiceTierChange("default"); }, expected: false, }, { name: "no model available at creation", setup: (s) => s.appendThinkingLevelChange("off"), expected: false }, { name: "model changed after creation", setup: (s) => { defaults(s); s.appendModelChange("openai", "gpt-5"); }, expected: true, }, { name: "thinking level changed after creation", setup: (s) => { defaults(s); s.appendThinkingLevelChange("high"); }, expected: true, }, { name: "thinking level changed on a no-model session", setup: (s) => { s.appendThinkingLevelChange("off"); s.appendThinkingLevelChange("high"); }, expected: true, }, { name: "Fast mode enabled after creation", setup: (s) => { defaults(s); s.appendServiceTierChange("default"); s.appendServiceTierChange("priority"); }, expected: true, }, { name: "a message was sent", setup: (s) => s.appendMessage(userMsg("hello")), expected: true }, { name: "the session was named", setup: (s) => { defaults(s); s.appendSessionInfo("my draft"); }, expected: true, }, ];
it.each(cases)("is $expected for: $name", ({ setup, expected }) => { const tempDir = mkdtempSync(join(tmpdir(), "has-user-content-")); try { const session = SessionManager.create(join(tempDir, "project"), join(tempDir, "sessions")); setup(session); expect(session.hasUserContent()).toBe(expected); } finally { rmSync(tempDir, { recursive: true, force: true }); } });});