import { existsSync } from "node:fs"; import { mkdtemp, readdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { SessionManager, SessionStore, countTurns, defaultSessionName, ensureMaiaHome, normalizeSessionName, validateSessionName, } from "./sessions"; import type { Message } from "./types"; const user = (text: string): Message => ({ role: "user", content: [{ type: "text", text }] }); const assistant = (text: string): Message => ({ role: "assistant", content: [{ type: "text", text }] }); const meta = { mode: "claudecode", model: "sapaicore:x", cwd: "/work" }; async function setup() { const root = await mkdtemp(join(tmpdir(), "maia-sessions-")); const home = ensureMaiaHome(root); const store = new SessionStore(home.sessionsDir); return { root, home, store }; } describe("dossiers ./.maia", () => { it("crée .maia et .maia/.sessions, created=true la première fois seulement", async () => { const root = await mkdtemp(join(tmpdir(), "maia-home-")); const first = ensureMaiaHome(root); expect(first.created).toBe(true); expect(existsSync(join(root, ".maia", ".sessions"))).toBe(true); expect(ensureMaiaHome(root).created).toBe(false); }); }); describe("noms", () => { it("nom par défaut : session_{date}_{heure}_{minutes}_{secondes}", () => { expect(defaultSessionName(new Date(2026, 9, 13, 1, 4, 5))).toBe("session_2026-10-13_01_04_05"); expect(defaultSessionName(new Date(2026, 11, 31, 23, 59, 59))).toBe("session_2026-12-31_23_59_59"); }); it("normalise les guillemets et valide les caractères", () => { expect(normalizeSessionName(' "mon test" ')).toBe("mon test"); expect(normalizeSessionName("'a b'")).toBe("a b"); expect(validateSessionName("mon test é")).toBeNull(); expect(validateSessionName("")).toContain("vide"); expect(validateSessionName("a/b")).toContain("interdits"); expect(validateSessionName("a:b")).toContain("interdits"); expect(validateSessionName(".caché")).toContain("point"); expect(validateSessionName("x".repeat(101))).toContain("trop long"); }); }); describe("SessionManager", () => { it("une session vide n'est pas sauvegardée", async () => { const { store } = await setup(); const m = new SessionManager(store); expect(m.save({ messages: [], pendingContext: [] }, meta)).toBeNull(); expect(store.names()).toEqual([]); }); it("sauvegarde sous le nom par défaut, relecture identique", async () => { const { store } = await setup(); const m = new SessionManager(store, () => new Date(2026, 9, 13, 1, 4, 5)); const messages = [user("bonjour"), assistant("salut")]; const r = m.save({ messages, pendingContext: ["ctx"] }, meta)!; expect(r).toMatchObject({ name: "session_2026-10-13_01_04_05", written: true }); const saved = await store.read(r.name); expect(saved).toMatchObject({ mode: "claudecode", model: "sapaicore:x", pendingContext: ["ctx"] }); expect(saved.messages).toEqual(messages); }); it("renommée avant la sortie : c'est ce nom qui est utilisé, et l'ancien fichier est déplacé", async () => { const { store } = await setup(); const m = new SessionManager(store); const state = { messages: [user("a")], pendingContext: [] }; const first = m.save(state, meta)!; expect(m.rename("ma session")).toEqual({ ok: true, name: "ma session", changed: true }); state.messages.push(assistant("b")); const second = m.save(state, meta)!; expect(second.name).toBe("ma session"); expect(store.names()).toEqual(["ma session"]); // l'ancien fichier a disparu expect(first.name).not.toBe("ma session"); }); it("rename : refuse un nom déjà pris par une autre session, accepte le sien", async () => { const { store } = await setup(); const a = new SessionManager(store); a.rename("alpha"); a.save({ messages: [user("x")], pendingContext: [] }, meta); const b = new SessionManager(store); const clash = b.rename("ALPHA"); expect(clash.ok).toBe(false); expect(b.rename("beta")).toMatchObject({ ok: true }); expect(a.rename("alpha")).toEqual({ ok: true, name: "alpha", changed: false }); expect(b.rename("a/b")).toMatchObject({ ok: false }); }); it("rien n'a changé : pas de réécriture ; nouveau message : réécriture", async () => { const { store } = await setup(); const m = new SessionManager(store); const state = { messages: [user("a")], pendingContext: [] }; expect(m.save(state, meta)!.written).toBe(true); expect(m.save(state, meta)!.written).toBe(false); state.messages.push(assistant("b")); expect(m.save(state, meta)!.written).toBe(true); }); it("homonyme apparu entre-temps : suffixe _2, jamais d'écrasement", async () => { const { store } = await setup(); const a = new SessionManager(store); a.rename("dup"); a.save({ messages: [user("A")], pendingContext: [] }, meta); const b = new SessionManager(store); (b as unknown as { name: string }).name = "dup"; // simule un nom devenu identique après coup const r = b.save({ messages: [user("B")], pendingContext: [] }, meta)!; expect(r.name).toBe("dup_2"); expect(store.names()).toEqual(["dup", "dup_2"]); }); it("restore puis sauvegarde : même fichier ; startNew : nouveau nom, fichier précédent intact", async () => { const { store } = await setup(); const m = new SessionManager(store); m.rename("travail"); const state = { messages: [user("a"), assistant("b")], pendingContext: [] }; m.save(state, meta); const other = new SessionManager(store); other.restore(await store.read("travail")); expect(other.name).toBe("travail"); const more = { messages: [...state.messages, user("c")], pendingContext: [] }; expect(other.save(more, meta)!.name).toBe("travail"); expect(store.names()).toEqual(["travail"]); other.startNew(); expect(other.name).toMatch(/^session_\d{4}-\d{2}-\d{2}_\d{2}_\d{2}_\d{2}$/); expect(store.names()).toEqual(["travail"]); }); }); describe("SessionStore", () => { it("find : exact puis insensible à la casse ; suggest", async () => { const { store } = await setup(); const m = new SessionManager(store); m.rename("Projet Alpha"); m.save({ messages: [user("x")], pendingContext: [] }, meta); expect(store.find("Projet Alpha")).toBe("Projet Alpha"); expect(store.find("projet alpha")).toBe("Projet Alpha"); expect(store.find("autre")).toBeUndefined(); expect(store.suggest("alpha")).toEqual(["Projet Alpha"]); }); it("list : plus récente d'abord, échanges comptés, fichiers invalides signalés", async () => { const { store, home } = await setup(); let t = 0; const clock = () => new Date(2026, 9, 13, 10, 0, t++); const a = new SessionManager(store, clock); a.rename("ancienne"); a.save({ messages: [user("a")], pendingContext: [] }, meta); const b = new SessionManager(store, clock); b.rename("récente"); b.save( { messages: [ user("q1"), { role: "assistant", content: [{ type: "tool_use", id: "1", name: "Read", input: {} }] }, { role: "user", content: [{ type: "tool_result", toolUseId: "1", content: "ok" }] }, assistant("fin"), user("q2"), ], pendingContext: [], }, meta, ); await writeFile(join(home.sessionsDir, "cassee.json"), "{pas du json"); const { sessions, unreadable } = await store.list(); expect(sessions.map((s) => s.name)).toEqual(["récente", "ancienne"]); expect(sessions[0]?.turns).toBe(2); // q1 et q2 ; le résultat d'outil n'est pas un échange expect(unreadable).toEqual(["cassee"]); await expect(store.read("cassee")).rejects.toThrow("corrompue"); }); it("aucun fichier temporaire ne reste après l'écriture", async () => { const { store, home } = await setup(); new SessionManager(store).save({ messages: [user("x")], pendingContext: [] }, meta); expect((await readdir(home.sessionsDir)).every((f) => f.endsWith(".json"))).toBe(true); }); it("countTurns", () => { expect(countTurns([user("a"), assistant("b"), user("c")])).toBe(2); }); });