MagicCode/packages/core/src/sessions.test.ts

197 lines
8.1 KiB
TypeScript
Raw Normal View History

2026-10-12 23:23:59 +00:00
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);
});
});