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[] = []; 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(Date.now() + 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 }); 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", }); service.close(); const reopened = new PersistenceService({ dbPath }); expect(reopened.getAppSettings()).toEqual({ codexBinaryPath: "/opt/codex/bin/codex", codexHomePath: "/Users/theo/.codex", }); 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"), }); 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 = { 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); expect(catchUp.events.every((event, index, events) => event.seq === events[index]?.seq)).toBe( true, ); 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); 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"), }); 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); service.close(); }); it("imports legacy renderer state once and reports idempotent re-imports", () => { const stateDir = makeTempDir("t3code-persistence-legacy-state-"); const projectDir = makeTempDir("t3code-persistence-legacy-project-"); const service = new PersistenceService({ dbPath: path.join(stateDir, "state.sqlite"), }); const firstImport = service.importLegacyRendererState({ projects: [ { id: "project-legacy-1", name: "legacy", cwd: projectDir, scripts: [], }, ], threads: [ { id: "thread-legacy-1", projectId: "project-legacy-1", title: "Imported", model: "gpt-5.3-codex", createdAt: "2026-02-19T00:00:00.000Z", messages: [ { id: "msg-legacy-1", role: "user", text: "legacy message", createdAt: "2026-02-19T00:00:01.000Z", streaming: false, }, ], }, ], }); expect(firstImport).toEqual({ imported: true, alreadyImported: false, }); const secondImport = service.importLegacyRendererState({ projects: [], threads: [], }); expect(secondImport).toEqual({ imported: false, alreadyImported: true, }); const snapshot = service.loadSnapshot(); expect(snapshot.projects).toHaveLength(1); expect(snapshot.threads).toHaveLength(1); expect(snapshot.threads[0]?.messages).toHaveLength(1); service.close(); }); });