196 lines
8.1 KiB
TypeScript
196 lines
8.1 KiB
TypeScript
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 = { model: "litellm: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({ model: "litellm: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);
|
|
});
|
|
});
|