feat(chat): conversations persistantes + mémoire multi-tour (v0.36.0)
Store SQLite conversations/messages (propriété par actor, fenêtre 20), historique rejoué au LLM (runAgentTurn history), ChatService persiste et renvoie conversationId. API GET/DELETE /conversations + chat avec conversationId. UI Chat: sidebar conversations (drawer mobile), nouvelle, reprise, suppression. docs/conversations.md. 83 tests verts, build web vert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016w5jRe87MGdd6AMvXQcHNi
This commit is contained in:
@@ -19,7 +19,7 @@ const auth: AuthConfig = {
|
||||
|
||||
// ChatService stub : pas de vrai LLM.
|
||||
const chatStub = {
|
||||
handle: async (_actor: string, text: string) => `echo:${text}`,
|
||||
handle: async (_actor: string, text: string) => ({ reply: `echo:${text}`, conversationId: null }),
|
||||
} as unknown as ChatService;
|
||||
|
||||
let app: FastifyInstance;
|
||||
@@ -29,7 +29,7 @@ beforeEach(async () => {
|
||||
repo = new AssetRepository(":memory:");
|
||||
const review = new ReviewService(repo, log);
|
||||
app = Fastify();
|
||||
await registerApi(app, { auth, chat: chatStub, review, state: () => ({ phase: "2-write-review", tools: 3 }) });
|
||||
await registerApi(app, { auth, chat: chatStub, review, conversations: null, state: () => ({ phase: "2-write-review", tools: 3 }) });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ConversationStore } from "../src/conversations/store.js";
|
||||
import { ChatService } from "../src/agent/chat-service.js";
|
||||
import type { OllamaClient, OllamaMessage } from "../src/llm/ollama.js";
|
||||
import type { Guard } from "../src/agent/types.js";
|
||||
import { createLogger } from "../src/audit/log.js";
|
||||
|
||||
const log = createLogger("silent");
|
||||
const allowGuard: Guard = { authorize: () => ({ allowed: true }) };
|
||||
|
||||
describe("ConversationStore", () => {
|
||||
it("crée, ajoute, relit dans l'ordre et liste par récence", () => {
|
||||
const s = new ConversationStore(":memory:");
|
||||
const a = s.create("api:owner", "Bonjour CHLOVA", 1000);
|
||||
s.append(a, "user", "Bonjour CHLOVA", 1000);
|
||||
s.append(a, "assistant", "Salut", 1001);
|
||||
const b = s.create("api:owner", "Autre sujet", 2000);
|
||||
s.append(b, "user", "Autre sujet", 2000);
|
||||
|
||||
expect(s.ownerOf(a)).toBe("api:owner");
|
||||
expect(s.messages(a).map((m) => m.role)).toEqual(["user", "assistant"]);
|
||||
// b plus récent (updated_at) → en tête de liste.
|
||||
expect(s.list("api:owner").map((c) => c.id)).toEqual([b, a]);
|
||||
expect(s.list("api:owner")[0]!.title).toBe("Autre sujet");
|
||||
s.close();
|
||||
});
|
||||
|
||||
it("recent() borne et conserve l'ordre chronologique", () => {
|
||||
const s = new ConversationStore(":memory:");
|
||||
const c = s.create("u", "x", 0);
|
||||
for (let i = 0; i < 30; i++) s.append(c, i % 2 ? "assistant" : "user", `m${i}`, i);
|
||||
const recent = s.recent(c, 5);
|
||||
expect(recent).toHaveLength(5);
|
||||
expect(recent.map((m) => m.content)).toEqual(["m25", "m26", "m27", "m28", "m29"]);
|
||||
s.close();
|
||||
});
|
||||
|
||||
it("isole les conversations par acteur", () => {
|
||||
const s = new ConversationStore(":memory:");
|
||||
s.create("a", "x");
|
||||
s.create("b", "y");
|
||||
expect(s.list("a")).toHaveLength(1);
|
||||
expect(s.list("b")).toHaveLength(1);
|
||||
s.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatService mémoire multi-tour", () => {
|
||||
// Client factice : snapshot (copie) des messages AU MOMENT de l'appel — le
|
||||
// tableau réel est muté ensuite par la boucle (push assistant), donc on copie.
|
||||
let lastLen = 0;
|
||||
let lastContents: string[] = [];
|
||||
const client = {
|
||||
chat: async (req: { messages: OllamaMessage[] }) => {
|
||||
lastLen = req.messages.length;
|
||||
lastContents = req.messages.map((m) => m.content);
|
||||
return { role: "assistant", content: "ok" } as OllamaMessage;
|
||||
},
|
||||
} as unknown as OllamaClient;
|
||||
|
||||
it("persiste et rejoue l'historique au tour suivant", async () => {
|
||||
const store = new ConversationStore(":memory:");
|
||||
const svc = new ChatService({
|
||||
client,
|
||||
tools: [],
|
||||
guard: allowGuard,
|
||||
systemPrompt: "SYS",
|
||||
logger: log,
|
||||
store,
|
||||
});
|
||||
|
||||
const r1 = await svc.handle("api:owner", "premier");
|
||||
expect(r1.conversationId).toBeTruthy();
|
||||
// Tour 1 : system + user = 2 messages, pas d'historique.
|
||||
expect(lastLen).toBe(2);
|
||||
|
||||
const r2 = await svc.handle("api:owner", "second", r1.conversationId!);
|
||||
expect(r2.conversationId).toBe(r1.conversationId);
|
||||
// Tour 2 : system + (user1, assistant1) + user2 = 4 messages.
|
||||
expect(lastLen).toBe(4);
|
||||
expect(lastContents).toEqual(["SYS", "premier", "ok", "second"]);
|
||||
|
||||
// 4 messages persistés (2 user + 2 assistant).
|
||||
expect(store.messages(r1.conversationId!)).toHaveLength(4);
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("refuse une conversation d'un autre acteur", async () => {
|
||||
const store = new ConversationStore(":memory:");
|
||||
const svc = new ChatService({ client, tools: [], guard: allowGuard, systemPrompt: "S", logger: log, store });
|
||||
const r = await svc.handle("alice", "coucou");
|
||||
await expect(svc.handle("bob", "intrus", r.conversationId!)).rejects.toThrow();
|
||||
store.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user