449 lines · 13.6 KB · TypeScript
RawDownload
import fs from "node:fs";import os from "node:os";import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import type { ProviderEvent } from "@t3tools/contracts";import { PersistenceService } from "./persistenceService"; const tempDirs: string[] = [];const ISO_BASE_MS = Date.parse("2026-02-19T00:00:00.000Z"); function makeTempDir(prefix: string): string {  const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));  tempDirs.push(dir);  return dir;} function iso(offsetMs = 0): string {  return new Date(ISO_BASE_MS + offsetMs).toISOString();} afterEach(() => {  for (const dir of tempDirs.splice(0, tempDirs.length)) {    fs.rmSync(dir, { recursive: true, force: true });  }}); describe("PersistenceService", () => {  it("stores app settings in metadata with partial updates", () => {    const stateDir = makeTempDir("t3code-persistence-settings-state-");    const dbPath = path.join(stateDir, "state.sqlite");    const service = new PersistenceService({ dbPath });     try {      expect(service.getAppSettings()).toEqual({        codexBinaryPath: "",        codexHomePath: "",      });       expect(        service.updateAppSettings({          codexBinaryPath: "  /opt/codex/bin/codex  ",        }),      ).toEqual({        codexBinaryPath: "/opt/codex/bin/codex",        codexHomePath: "",      });       expect(        service.updateAppSettings({          codexHomePath: "  /Users/theo/.codex  ",        }),      ).toEqual({        codexBinaryPath: "/opt/codex/bin/codex",        codexHomePath: "/Users/theo/.codex",      });    } finally {      service.close();    }     const reopened = new PersistenceService({ dbPath });    try {      expect(reopened.getAppSettings()).toEqual({        codexBinaryPath: "/opt/codex/bin/codex",        codexHomePath: "/Users/theo/.codex",      });    } finally {      reopened.close();    }  });   it("persists projects/threads/messages and serves bootstrap + catch-up", () => {    const stateDir = makeTempDir("t3code-persistence-state-");    const projectDir = makeTempDir("t3code-persistence-project-");    const service = new PersistenceService({      dbPath: path.join(stateDir, "state.sqlite"),    });     try {      const addedProject = service.addProject({ cwd: projectDir });      expect(addedProject.created).toBe(true);       const createdThread = service.createThread({        projectId: addedProject.project.id,        title: "Thread 1",        model: "gpt-5.3-codex",      }).thread;       service.bindSessionToThread("sess-1", createdThread.id, "runtime-thread-1");      service.persistUserMessageForTurn({        sessionId: "sess-1",        clientMessageId: "msg-user-1",        clientMessageText: "hello world",        input: "hello world",        attachments: [],      });       const baseEvent: Omit<ProviderEvent, "id" | "createdAt" | "method" | "payload"> = {        kind: "notification",        provider: "codex",        sessionId: "sess-1",      };       service.ingestProviderEvent({        ...baseEvent,        id: "evt-1",        createdAt: iso(10),        method: "item/started",        payload: {          item: {            type: "agentMessage",            id: "assistant-msg-1",            text: "",          },        },      });       service.ingestProviderEvent({        ...baseEvent,        id: "evt-2",        createdAt: iso(20),        method: "item/agentMessage/delta",        itemId: "assistant-msg-1",        textDelta: "hi",        payload: {          itemId: "assistant-msg-1",          delta: "hi",        },      });       service.ingestProviderEvent({        ...baseEvent,        id: "evt-3",        createdAt: iso(30),        method: "item/completed",        payload: {          item: {            type: "agentMessage",            id: "assistant-msg-1",            text: "hi there",          },        },      });       service.ingestProviderEvent({        ...baseEvent,        id: "evt-4",        createdAt: iso(40),        method: "turn/completed",        turnId: "turn-1",        payload: {          turn: {            id: "turn-1",            status: "completed",          },        },      });       const snapshot = service.loadSnapshot();      expect(snapshot.projects).toHaveLength(1);      expect(snapshot.threads).toHaveLength(1);      const thread = snapshot.threads[0];      expect(thread?.messages.map((message) => message.id)).toEqual([        "msg-user-1",        "assistant-msg-1",      ]);      expect(thread?.messages[1]?.text).toBe("hi there");      expect(thread?.turnDiffSummaries[0]?.turnId).toBe("turn-1");      expect(thread?.turnDiffSummaries[0]?.assistantMessageId).toBe("assistant-msg-1");       const catchUp = service.catchUp({ afterSeq: 0 });      expect(catchUp.events.length).toBeGreaterThan(0);      const sequences = catchUp.events.map((event) => event.seq);      expect(sequences.every((sequence) => Number.isInteger(sequence))).toBe(true);      for (let index = 1; index < sequences.length; index += 1) {        expect(sequences[index]).toBeGreaterThan(sequences[index - 1] ?? Number.NEGATIVE_INFINITY);      }      expect(catchUp.events.some((event) => event.eventType === "project.upsert")).toBe(true);      expect(catchUp.events.some((event) => event.eventType === "thread.upsert")).toBe(true);      expect(catchUp.events.some((event) => event.eventType === "message.upsert")).toBe(true);    } finally {      service.close();    }  });   it("stores turn diff summaries parsed from checkpoint diffs", () => {    const stateDir = makeTempDir("t3code-persistence-diff-state-");    const projectDir = makeTempDir("t3code-persistence-diff-project-");    const service = new PersistenceService({      dbPath: path.join(stateDir, "state.sqlite"),    });     try {      const project = service.addProject({ cwd: projectDir }).project;      const thread = service.createThread({        projectId: project.id,        title: "Thread 1",        model: "gpt-5.3-codex",      }).thread;      service.bindSessionToThread("sess-1", thread.id, "runtime-thread-1");       service.persistTurnDiffSummaryFromCheckpoint({        sessionId: "sess-1",        runtimeThreadId: "runtime-thread-1",        turnId: "turn-1",        checkpointTurnCount: 1,        completedAt: "2026-02-19T00:00:00.000Z",        status: "completed",        diff: [          "diff --git a/src/app.ts b/src/app.ts",          "index 1111111..2222222 100644",          "--- a/src/app.ts",          "+++ b/src/app.ts",          "@@ -1,1 +1,2 @@",          "-console.log('a')",          "+console.log('b')",          "+console.log('c')",        ].join("\n"),      });       service.ingestProviderEvent({        id: "evt-agent-msg-1",        kind: "notification",        provider: "codex",        sessionId: "sess-1",        createdAt: "2026-02-19T00:00:01.000Z",        method: "item/completed",        turnId: "turn-1",        itemId: "assistant-msg-1",        payload: {          item: {            type: "agentMessage",            id: "assistant-msg-1",            text: "done",          },          threadId: "runtime-thread-1",          turnId: "turn-1",        },      });       const snapshot = service.loadSnapshot();      const summary = snapshot.threads[0]?.turnDiffSummaries[0];      expect(summary?.turnId).toBe("turn-1");      expect(summary?.checkpointTurnCount).toBe(1);      expect(summary?.assistantMessageId).toBe("assistant-msg-1");      expect(summary?.files[0]?.path).toBe("src/app.ts");      expect(summary?.files[0]?.additions).toBe(2);      expect(summary?.files[0]?.deletions).toBe(1);    } finally {      service.close();    }  });   it("advances listMessages.nextOffset by fetched row count even when rows fail to parse", () => {    const stateDir = makeTempDir("t3code-persistence-list-messages-state-");    const projectDir = makeTempDir("t3code-persistence-list-messages-project-");    const service = new PersistenceService({      dbPath: path.join(stateDir, "state.sqlite"),    });     try {      const project = service.addProject({ cwd: projectDir }).project;      const thread = service.createThread({        projectId: project.id,        title: "Thread 1",        model: "gpt-5.3-codex",      }).thread;      service.bindSessionToThread("sess-1", thread.id, "runtime-thread-1");       service.persistUserMessageForTurn({        sessionId: "sess-1",        clientMessageId: "msg-1",        clientMessageText: "first",        input: "first",        attachments: [],      });      service.persistUserMessageForTurn({        sessionId: "sess-1",        clientMessageId: "msg-2",        clientMessageText: "second",        input: "second",        attachments: [],      });       const db = (        service as unknown as {          db: { prepare: (sql: string) => { run: (...params: unknown[]) => unknown } };        }      ).db;      db.prepare("UPDATE documents SET data_json = ? WHERE id = ?;").run(        "{bad-json",        `message:${thread.id}:msg-1`,      );       const firstPage = service.listMessages({        threadId: thread.id,        limit: 1,        offset: 0,      });      expect(firstPage.messages).toEqual([]);      expect(firstPage.total).toBe(2);      expect(firstPage.nextOffset).toBe(1);       const secondPage = service.listMessages({        threadId: thread.id,        limit: 1,        offset: firstPage.nextOffset ?? 0,      });      expect(secondPage.messages.map((message) => message.id)).toEqual(["msg-2"]);      expect(secondPage.nextOffset).toBeNull();    } finally {      service.close();    }  });   it("does not throw when state event listeners fail after commit", () => {    const stateDir = makeTempDir("t3code-persistence-state-events-");    const projectDir = makeTempDir("t3code-persistence-state-events-project-");    const service = new PersistenceService({      dbPath: path.join(stateDir, "state.sqlite"),    });     try {      service.on("stateEvent", () => {        throw new Error("listener failed");      });       const result = service.addProject({ cwd: projectDir });      expect(result.created).toBe(true);      expect(service.listProjects()).toHaveLength(1);    } finally {      service.close();    }  });   it("preserves checkpointTurnCount=0 when merging turn summaries", () => {    const stateDir = makeTempDir("t3code-persistence-checkpoint-zero-state-");    const projectDir = makeTempDir("t3code-persistence-checkpoint-zero-project-");    const service = new PersistenceService({      dbPath: path.join(stateDir, "state.sqlite"),    });     try {      const project = service.addProject({ cwd: projectDir }).project;      const thread = service.createThread({        projectId: project.id,        title: "Thread 1",        model: "gpt-5.3-codex",      }).thread;      service.bindSessionToThread("sess-1", thread.id, "runtime-thread-1");       service.persistTurnDiffSummaryFromCheckpoint({        sessionId: "sess-1",        runtimeThreadId: "runtime-thread-1",        turnId: "turn-1",        checkpointTurnCount: 0,        completedAt: "2026-02-19T00:00:00.000Z",        diff: "",      });       service.ingestProviderEvent({        id: "evt-assistant-completed",        kind: "notification",        provider: "codex",        sessionId: "sess-1",        createdAt: "2026-02-19T00:00:01.000Z",        method: "item/completed",        turnId: "turn-1",        payload: {          item: {            type: "agentMessage",            id: "assistant-msg-1",            text: "done",          },        },      });       const snapshot = service.loadSnapshot();      const summary = snapshot.threads[0]?.turnDiffSummaries.find(        (entry) => entry.turnId === "turn-1",      );      expect(summary?.checkpointTurnCount).toBe(0);      expect(summary?.assistantMessageId).toBe("assistant-msg-1");    } finally {      service.close();    }  });   it("does not let out-of-order item/started overwrite accumulated assistant text", () => {    const stateDir = makeTempDir("t3code-persistence-item-order-state-");    const projectDir = makeTempDir("t3code-persistence-item-order-project-");    const service = new PersistenceService({      dbPath: path.join(stateDir, "state.sqlite"),    });     try {      const project = service.addProject({ cwd: projectDir }).project;      const thread = service.createThread({        projectId: project.id,        title: "Thread 1",        model: "gpt-5.3-codex",      }).thread;      service.bindSessionToThread("sess-1", thread.id, "runtime-thread-1");       service.ingestProviderEvent({        id: "evt-delta",        kind: "notification",        provider: "codex",        sessionId: "sess-1",        createdAt: "2026-02-19T00:00:02.000Z",        method: "item/agentMessage/delta",        itemId: "assistant-msg-1",        textDelta: "hello",        payload: {          itemId: "assistant-msg-1",          delta: "hello",        },      });       service.ingestProviderEvent({        id: "evt-started-late",        kind: "notification",        provider: "codex",        sessionId: "sess-1",        createdAt: "2026-02-19T00:00:03.000Z",        method: "item/started",        payload: {          item: {            type: "agentMessage",            id: "assistant-msg-1",            text: "",          },        },      });       const messages = service.listMessages({        threadId: thread.id,        limit: 10,        offset: 0,      }).messages;      const assistantMessage = messages.find((message) => message.id === "assistant-msg-1");      expect(assistantMessage?.text).toBe("hello");    } finally {      service.close();    }  }); });