From f58d37950587058cfe3d793a3b8b85c5b8d253aa Mon Sep 17 00:00:00 2001 From: Rhuan Barreto Date: Wed, 6 May 2026 13:27:08 +0200 Subject: [PATCH] fix: rewrite opencode session-context to read from SQLite database Opencode migrated from JSON files (storage/session/, storage/message/) to a SQLite database (opencode.db). The previous implementation read from directories that no longer exist, causing session-context to always fail with "No opencode session storage found". Rewrite readOpencodeSession to query the SQLite database directly using bun:sqlite. Sessions are matched by comparing the directory column to the project root. Messages and parts are joined to build the transcript, with synthetic parts (editor context) filtered out. Add opencodeDbPath() helper to paths.ts for resolving the database location ($XDG_DATA_HOME/opencode/opencode.db). Rewrite all tests to use SQLite fixtures with try/catch cleanup for Windows EBUSY resilience. Signed-off-by: Rhuan Barreto --- .../agent-memory/archgate-developer/MEMORY.md | 1 + src/helpers/paths.ts | 16 + src/helpers/session-context-opencode.ts | 278 ++++++------- .../helpers/session-context-opencode.test.ts | 388 ++++++++++++++---- 4 files changed, 438 insertions(+), 245 deletions(-) diff --git a/.claude/agent-memory/archgate-developer/MEMORY.md b/.claude/agent-memory/archgate-developer/MEMORY.md index ddf269fb..8e4dc6d3 100644 --- a/.claude/agent-memory/archgate-developer/MEMORY.md +++ b/.claude/agent-memory/archgate-developer/MEMORY.md @@ -48,6 +48,7 @@ Skipping steps 2 or 3 is a workflow violation. The user should NEVER have to inv - **PowerShell 5.1 reads BOM-less `.ps1` files as ANSI — never use non-ASCII chars in `install.ps1`** — `install.ps1` has no UTF-8 BOM (first bytes are `# A...`). On Windows PowerShell 5.1, this means the file is decoded as the system codepage (Windows-1252), so multi-byte UTF-8 characters like em-dash (`—`, `\xE2\x80\x94`) get split into garbage bytes that break later string parsing — the parser reports cryptic errors like "string is missing the terminator" on lines that look fine. ALWAYS stick to ASCII (`-`, `--`, straight quotes) in comments and string literals in `install.ps1`. To verify after editing: `[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path .\install.ps1).Path, [ref]$null, [ref]$errs)`. Same caveat applies to any unsigned `.ps1` file the project distributes. - **SLSA reusable workflow MUST be tag-pinned, not SHA-pinned** — `slsa-framework/slsa-github-generator/.github/workflows/*` looks like it should follow CI-001 (SHA pin), but the SLSA generator's `generate-builder.sh` reads the workflow ref to download the prebuilt builder from a GitHub release and rejects non-tag refs (`Invalid ref: ... Expected ref of the form refs/tags/vX.Y.Z`, exit 2). Pinning by SHA broke the v0.31.0 release ([run 25107195589](https://github.com/archgate/cli/actions/runs/25107195589)). The CI-001 rule allowlists this path so it does NOT block a SHA repin — meaning a future agent could "fix" the tag pin and the rule would be silent until the next release fails. ALWAYS keep `@v2.x.y` for `release-binaries.yml:165` and read the inline comment + CI-001 "Carved-out exceptions" before changing. Upstream issue: [slsa-framework/slsa-github-generator#150](https://github.com/slsa-framework/slsa-github-generator/issues/150). - **Windows binary upgrade: never use detached child processes for `.old` cleanup** — On Windows, `replaceBinary()` renames the running exe to `.old` because the OS file-locks it. Cleaning up the `.old` via a detached `cmd /c ping -n 2 ... & del` process is unreliable (process may not spawn, `del` may fail silently, timing races). Instead, `cleanupStaleBinary()` runs at the next CLI startup as a fire-and-forget `unlink()` — the file is guaranteed unlocked by then. The cleanup is platform-agnostic (uses `getArtifactInfo()` to resolve the binary name), so it works on any supported platform even though only Windows currently creates `.old` files. The sync `unlinkSync` in `replaceBinary()` is kept as defense-in-depth for leftover `.old` files from previous upgrades. Do NOT reintroduce detached cleanup processes. +- **`bun:sqlite` file handles persist after `db.close()` on Windows — wrap test cleanup in try/catch** — Tests that create temp SQLite databases via `new Database(path)` will fail with `EBUSY: resource busy or locked` when `rmSync` tries to remove the temp directory in `afterEach`, even after calling `db.close()`. Windows holds the file handle briefly. Fix: (1) set `PRAGMA journal_mode = DELETE` in test DBs to avoid creating WAL/SHM files, and (2) wrap `rmSync` in `afterEach` with `try { rmSync(...) } catch { /* SQLite handles may persist */ }`. Each test must use a unique temp dir name so leftover files don't collide. - **`GITHUB_TOKEN`-authored pushes do NOT trigger downstream workflows — release.yml MUST use the GH App token** — When an Actions workflow pushes commits or opens PRs using `${{ github.token }}` / `secrets.GITHUB_TOKEN`, GitHub intentionally suppresses the resulting `push` / `pull_request` events to prevent recursion. Symptom on release PRs: the head SHA has no `pull_request`-event check runs, so `Validate Code` / `Lint, Test & Check` / `DCO Sign-off Check` are missing from the PR rollup and branch protection treats the PR as missing required checks. PR [#131](https://github.com/archgate/cli/pull/131) papered over this by manually `gh workflow run` + posting commit statuses, but `workflow_dispatch` runs land on `head_branch: release` with `pull_requests: []` — they are not associated with the PR ref, so `Lint, Test & Check` stayed orphaned and the bug recurred on PR [#251](https://github.com/archgate/cli/pull/251). Root-cause fix: in `release.yml` the `pull-request` job MUST generate a GitHub App installation token via `actions/create-github-app-token` (using `secrets.GH_APP_APP_ID` / `secrets.GH_APP_PRIVATE_KEY`) and pass it to BOTH `actions/checkout` and `simple-release-action`. App-token-authored pushes DO trigger `pull_request` events naturally. Apply the same pattern to any future workflow that pushes to a branch whose downstream CI must run. ## Validation Pipeline diff --git a/src/helpers/paths.ts b/src/helpers/paths.ts index 505a96ff..d2ed96c0 100644 --- a/src/helpers/paths.ts +++ b/src/helpers/paths.ts @@ -88,6 +88,22 @@ export function opencodeStorageDir(): string { return join(base, "opencode", "storage"); } +/** + * Resolve the opencode SQLite database path. + * + * Opencode stores session/message/part data in a SQLite database at + * `$XDG_DATA_HOME/opencode/opencode.db` (defaulting to + * `~/.local/share/opencode/opencode.db`). + * + * Resolved at call time (not cached) so tests can override HOME / + * XDG_DATA_HOME. + */ +export function opencodeDbPath(): string { + const xdg = usableEnv(Bun.env.XDG_DATA_HOME); + const base = xdg ?? join(archgateHomeDir(), ".local", "share"); + return join(base, "opencode", "opencode.db"); +} + export const paths = { cacheFolder: internalPath("cache") } as const; export function projectPath(projectRoot: string, ...path: string[]) { diff --git a/src/helpers/session-context-opencode.ts b/src/helpers/session-context-opencode.ts index 64cb131b..c09a3f09 100644 --- a/src/helpers/session-context-opencode.ts +++ b/src/helpers/session-context-opencode.ts @@ -1,8 +1,9 @@ -import { readdirSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { Database } from "bun:sqlite"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import { logDebug } from "./log"; -import { opencodeStorageDir } from "./paths"; +import { opencodeDbPath } from "./paths"; import { isWindows } from "./platform"; import { RELEVANT_ROLES, @@ -36,190 +37,157 @@ function normalizePath(p: string): string { return isWindows() ? resolved.toLowerCase() : resolved; } -interface SessionMeta { - id: string; - path: string; - updatedAt: number; - projectHash: string; -} - /** * Read an opencode session transcript for a project. * - * Opencode stores data under `~/.local/share/opencode/storage/`: - * - `session//.json` — session metadata - * - `message//.json` — individual messages + * Opencode stores data in a SQLite database at + * `$XDG_DATA_HOME/opencode/opencode.db` (default `~/.local/share/opencode/opencode.db`): + * - `session` table — session metadata with `directory` for project matching + * - `message` table — messages with `role` in the `data` JSON column + * - `part` table — content parts with `type` and `text` in the `data` JSON column * - * Sessions are matched by comparing the `path` field in session metadata + * Sessions are matched by comparing the `directory` field in session rows * to the provided project root. */ -export async function readOpencodeSession( +export function readOpencodeSession( projectRoot: string | null, options?: ReadOpencodeSessionOptions -): Promise { +): OpencodeSessionResult { const limit = options?.maxEntries ?? 200; - const storageDir = opencodeStorageDir(); - const sessionsRoot = join(storageDir, "session"); + const dbPath = opencodeDbPath(); const normalizedProjectRoot = normalizePath(projectRoot ?? process.cwd()); - // 1. Scan session// directories for session JSON files - const allSessions: SessionMeta[] = []; + if (!existsSync(dbPath)) { + return { ok: false, error: "No opencode database found", path: dbPath }; + } - let projectHashDirs: string[]; + let db: Database; try { - projectHashDirs = readdirSync(sessionsRoot).filter((name) => { - try { - return statSync(join(sessionsRoot, name)).isDirectory(); - } catch { - return false; - } - }); + db = new Database(dbPath, { readonly: true }); } catch { return { ok: false, - error: "No opencode session storage found", - path: sessionsRoot, + error: "Failed to open opencode database", + path: dbPath, }; } - for (const hashDir of projectHashDirs) { - const hashPath = join(sessionsRoot, hashDir); - let sessionFiles: string[]; - try { - sessionFiles = readdirSync(hashPath).filter((f) => f.endsWith(".json")); - } catch { - continue; + try { + // 1. Find all sessions, sorted by most recently updated first + interface SessionRow { + id: string; + directory: string; + time_updated: number; + } + const allSessions = db + .query( + "SELECT id, directory, time_updated FROM session ORDER BY time_updated DESC" + ) + .all(); + + if (allSessions.length === 0) { + return { ok: false, error: "No opencode sessions found", path: dbPath }; } - for (const file of sessionFiles) { - try { - // oxlint-disable-next-line no-await-in-loop -- sequential read needed: each session file determines project match - const raw = await Bun.file(join(hashPath, file)).json(); - const meta = raw as Record; - const id = typeof meta.id === "string" ? meta.id : null; - const sessionPath = typeof meta.path === "string" ? meta.path : null; - if (!id) continue; - // Parse updated_at — may be ISO string or camelCase variant - let updatedAt = 0; - if (typeof meta.updated_at === "string") { - updatedAt = new Date(meta.updated_at).getTime(); - } else if (typeof meta.updatedAt === "string") { - updatedAt = new Date(meta.updatedAt as string).getTime(); - } else if (typeof meta.updated_at === "number") { - updatedAt = meta.updated_at; - } - - allSessions.push({ - id, - path: sessionPath ?? "", - updatedAt, - projectHash: hashDir, - }); - } catch { - logDebug(`Skipping session file ${file}: parse error`); - } + // 2. Filter sessions by project path + const matching = allSessions.filter( + (s) => s.directory && normalizePath(s.directory) === normalizedProjectRoot + ); + + if (matching.length === 0) { + return { + ok: false, + error: "No opencode sessions found for this project", + path: dbPath, + available: allSessions.map((s) => s.id), + }; } - } - if (allSessions.length === 0) { - return { - ok: false, - error: "No opencode sessions found", - path: sessionsRoot, - }; - } + // 3. Select session by ID or most recent + const target = options?.sessionId + ? matching.find((s) => s.id === options.sessionId) + : matching[0]; + + if (!target) { + return { + ok: false, + error: `Session not found: ${options?.sessionId}`, + available: matching.map((s) => s.id), + }; + } - // 2. Filter sessions by project path - const matching = allSessions - .filter((s) => s.path && normalizePath(s.path) === normalizedProjectRoot) - .sort((a, b) => b.updatedAt - a.updatedAt); + // 4. Read messages for the session + interface MessageRow { + id: string; + role: string; + } + const messages = db + .query( + "SELECT id, json_extract(data, '$.role') as role FROM message WHERE session_id = ? ORDER BY time_created" + ) + .all(target.id); + + if (messages.length === 0) { + return { + ok: false, + error: "Session exists but has no messages", + path: dbPath, + }; + } - if (matching.length === 0) { - return { - ok: false, - error: "No opencode sessions found for this project", - path: sessionsRoot, - available: allSessions.map((s) => s.id), - }; - } + // 5. Build transcript from text parts, skipping synthetic entries + interface PartRow { + type: string; + text: string | null; + tool: string | null; + } + const partsQuery = db.prepare( + "SELECT json_extract(data, '$.type') as type, json_extract(data, '$.text') as text, json_extract(data, '$.tool') as tool FROM part WHERE message_id = ? AND json_extract(data, '$.synthetic') IS NOT 1 ORDER BY time_created" + ); + + const relevant: OpencodeSessionSummary["transcript"] = []; + for (const msg of messages) { + if (!RELEVANT_ROLES.has(msg.role)) continue; + + const parts = partsQuery.all(msg.id); + + const contentParts: string[] = []; + for (const part of parts) { + if (part.type === "text" && part.text) { + contentParts.push(part.text); + } else if (part.type === "tool" && part.tool) { + contentParts.push(`[tool: ${part.tool}]`); + } + } - // 3. Select session by ID or most recent - const target = options?.sessionId - ? matching.find((s) => s.id === options.sessionId) - : matching[0]; + const content = contentParts.join("\n"); + if (content.length === 0) continue; - if (!target) { - return { - ok: false, - error: `Session not found: ${options?.sessionId}`, - available: matching.map((s) => s.id), - }; - } + const normalized: TranscriptEntry = { message: { content } }; + relevant.push({ + role: msg.role, + contentPreview: getContentPreview(normalized), + }); + } - // 4. Read message files from message// - const messagesDir = join(storageDir, "message", target.id); - let messageFiles: string[]; - try { - messageFiles = readdirSync(messagesDir) - .filter((f) => f.endsWith(".json")) - .sort(); // Lexicographic sort — message IDs are ordered - } catch { + const trimmed = relevant.length > limit ? relevant.slice(-limit) : relevant; return { - ok: false, - error: "Session exists but has no messages", - path: messagesDir, + ok: true, + data: { + sessionId: target.id, + totalEntries: messages.length, + relevantEntries: relevant.length, + transcript: trimmed, + }, }; - } - - if (messageFiles.length === 0) { + } catch (err) { + logDebug(`Failed to read opencode database: ${String(err)}`); return { ok: false, - error: "Session exists but message directory is empty", - path: messagesDir, + error: "Failed to read opencode database", + path: dbPath, }; + } finally { + db.close(); } - - // 5. Parse messages, filter to user/assistant, extract previews - interface MessageData { - role?: string; - content?: unknown; - } - const allMessages: MessageData[] = []; - for (const file of messageFiles) { - try { - // oxlint-disable-next-line no-await-in-loop -- sequential read needed: message files must be read in order - const data = (await Bun.file(join(messagesDir, file)).json()) as Record< - string, - unknown - >; - allMessages.push({ - role: typeof data.role === "string" ? data.role : undefined, - content: data.content, - }); - } catch { - logDebug(`Skipping message file ${file}: parse error`); - } - } - - const relevant: OpencodeSessionSummary["transcript"] = []; - for (const msg of allMessages) { - if (!RELEVANT_ROLES.has(msg.role ?? "")) continue; - // Normalize to TranscriptEntry shape for getContentPreview - const normalized: TranscriptEntry = { message: { content: msg.content } }; - relevant.push({ - role: msg.role!, - contentPreview: getContentPreview(normalized), - }); - } - - const trimmed = relevant.length > limit ? relevant.slice(-limit) : relevant; - return { - ok: true, - data: { - sessionId: target.id, - totalEntries: allMessages.length, - relevantEntries: relevant.length, - transcript: trimmed, - }, - }; } diff --git a/tests/helpers/session-context-opencode.test.ts b/tests/helpers/session-context-opencode.test.ts index 13a4e517..816d7b18 100644 --- a/tests/helpers/session-context-opencode.test.ts +++ b/tests/helpers/session-context-opencode.test.ts @@ -1,85 +1,136 @@ +import { Database } from "bun:sqlite"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; +import { mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { readOpencodeSession } from "../../src/helpers/session-context-opencode"; -// This file covers readOpencodeSession happy-path and error-case tests. - +/** + * Tests for readOpencodeSession — reads session data from + * opencode's SQLite database. + */ describe("readOpencodeSession", () => { const uniqueId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const projectRoot = resolve(`/__archgate_opencode_test_${uniqueId}`); - // Use a temp storage dir under homedir to avoid polluting real opencode data - const projectHash = `proj_${uniqueId}`; - let storageDir: string; - let sessionBaseDir: string; - let messageBaseDir: string; + let tempDir: string; + let dbPath: string; + let originalXdg: string | undefined; beforeEach(() => { - storageDir = join(homedir(), ".local", "share", "opencode", "storage"); - sessionBaseDir = join(storageDir, "session"); - messageBaseDir = join(storageDir, "message"); - mkdirSync(sessionBaseDir, { recursive: true }); - mkdirSync(messageBaseDir, { recursive: true }); + tempDir = join( + tmpdir(), + `archgate-opencode-test-${uniqueId}-${Date.now()}` + ); + mkdirSync(join(tempDir, "opencode"), { recursive: true }); + dbPath = join(tempDir, "opencode", "opencode.db"); + + // Point opencodeDbPath() to our temp directory + originalXdg = Bun.env.XDG_DATA_HOME; + Bun.env.XDG_DATA_HOME = tempDir; }); afterEach(() => { - for (const dir of createdDirs) { - rmSync(dir, { recursive: true, force: true }); + if (originalXdg === undefined) { + delete Bun.env.XDG_DATA_HOME; + } else { + Bun.env.XDG_DATA_HOME = originalXdg; + } + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // On Windows, SQLite file handles may persist briefly after close. + // Temp dirs use unique names, so leftover files don't affect other tests. } - createdDirs.length = 0; }); - const createdDirs: string[] = []; + /** Create the opencode database schema. */ + function createDb(): Database { + const db = new Database(dbPath); + // Use DELETE journal mode to avoid WAL/SHM files that lock on Windows + db.exec("PRAGMA journal_mode = DELETE"); + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL DEFAULT '', + parent_id TEXT, + slug TEXT NOT NULL DEFAULT '', + directory TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + version TEXT NOT NULL DEFAULT '', + time_created INTEGER NOT NULL DEFAULT 0, + time_updated INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, + time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL DEFAULT 0, + time_updated INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL DEFAULT '{}' + ); + `); + return db; + } + /** Insert a session with messages and text parts. */ function makeSession( + db: Database, sessionId: string, - sessionPath: string, - messages?: Array<{ id: string; role: string; content: string }> + sessionDir: string, + messages?: Array<{ id: string; role: string; content: string }>, + timeUpdated?: number ): void { - // Create session metadata file - const sessionDir = join(sessionBaseDir, projectHash); - mkdirSync(sessionDir, { recursive: true }); - createdDirs.push(sessionDir); - - const sessionMeta = { - id: sessionId, - title: `Test session ${sessionId}`, - path: sessionPath, - updated_at: new Date().toISOString(), - created_at: new Date().toISOString(), - }; - writeFileSync( - join(sessionDir, `${sessionId}.json`), - JSON.stringify(sessionMeta) + const now = timeUpdated ?? Date.now(); + db.run( + "INSERT INTO session (id, directory, time_created, time_updated) VALUES (?, ?, ?, ?)", + [sessionId, sessionDir, now, now] ); - // Create message files - if (messages) { - const msgDir = join(messageBaseDir, sessionId); - mkdirSync(msgDir, { recursive: true }); - createdDirs.push(msgDir); - - for (const msg of messages) { - const msgData = { - id: msg.id, - role: msg.role, - content: msg.content, - session_id: sessionId, - created_at: new Date().toISOString(), - }; - writeFileSync(join(msgDir, `${msg.id}.json`), JSON.stringify(msgData)); - } + if (!messages) return; + + let msgTime = now; + for (const msg of messages) { + msgTime += 1; + db.run( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", + [ + msg.id, + sessionId, + msgTime, + msgTime, + JSON.stringify({ role: msg.role }), + ] + ); + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + [ + `prt_${msg.id}`, + msg.id, + sessionId, + msgTime, + msgTime, + JSON.stringify({ type: "text", text: msg.content }), + ] + ); } } test("returns data for most recent session matching project", async () => { + const db = createDb(); const sessionId = `ses_${uniqueId}_1`; - makeSession(sessionId, projectRoot, [ + makeSession(db, sessionId, projectRoot, [ { id: "msg_001", role: "user", content: "hello opencode" }, { id: "msg_002", role: "assistant", content: "hi there" }, ]); + db.close(); const result = await readOpencodeSession(projectRoot); expect(result.ok).toBe(true); @@ -99,15 +150,25 @@ describe("readOpencodeSession", () => { }); test("finds session by sessionId", async () => { + const db = createDb(); const sessionId1 = `ses_${uniqueId}_first`; const sessionId2 = `ses_${uniqueId}_second`; - makeSession(sessionId1, projectRoot, [ - { id: "msg_001", role: "user", content: "first session" }, - ]); - makeSession(sessionId2, projectRoot, [ - { id: "msg_001", role: "user", content: "second session" }, - ]); + makeSession( + db, + sessionId1, + projectRoot, + [{ id: "msg_001", role: "user", content: "first session" }], + 1000 + ); + makeSession( + db, + sessionId2, + projectRoot, + [{ id: "msg_002", role: "user", content: "second session" }], + 2000 + ); + db.close(); const result = await readOpencodeSession(projectRoot, { sessionId: sessionId1, @@ -120,10 +181,12 @@ describe("readOpencodeSession", () => { }); test("returns error when sessionId not found (with available list)", async () => { + const db = createDb(); const sessionId = `ses_${uniqueId}_real`; - makeSession(sessionId, projectRoot, [ + makeSession(db, sessionId, projectRoot, [ { id: "msg_001", role: "user", content: "real" }, ]); + db.close(); const result = await readOpencodeSession(projectRoot, { sessionId: "nonexistent-id", @@ -136,13 +199,43 @@ describe("readOpencodeSession", () => { }); test("filters to user/assistant roles only", async () => { + const db = createDb(); const sessionId = `ses_${uniqueId}_roles`; - makeSession(sessionId, projectRoot, [ - { id: "msg_001", role: "system", content: "system msg" }, - { id: "msg_002", role: "tool", content: "tool output" }, - { id: "msg_003", role: "user", content: "visible" }, - { id: "msg_004", role: "assistant", content: "also visible" }, - ]); + + // Insert messages with various roles — system and tool should be filtered out + const now = Date.now(); + db.run( + "INSERT INTO session (id, directory, time_created, time_updated) VALUES (?, ?, ?, ?)", + [sessionId, projectRoot, now, now] + ); + + const roles = ["system", "tool", "user", "assistant"]; + const contents = ["system msg", "tool output", "visible", "also visible"]; + for (let i = 0; i < roles.length; i++) { + const msgId = `msg_${String(i).padStart(3, "0")}`; + db.run( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", + [ + msgId, + sessionId, + now + i + 1, + now + i + 1, + JSON.stringify({ role: roles[i] }), + ] + ); + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + [ + `prt_${msgId}`, + msgId, + sessionId, + now + i + 1, + now + i + 1, + JSON.stringify({ type: "text", text: contents[i] }), + ] + ); + } + db.close(); const result = await readOpencodeSession(projectRoot); expect(result.ok).toBe(true); @@ -155,10 +248,12 @@ describe("readOpencodeSession", () => { }); test("returns error when no sessions match the project", async () => { + const db = createDb(); const sessionId = `ses_${uniqueId}_other`; - makeSession(sessionId, "/some/other/project", [ + makeSession(db, sessionId, "/some/other/project", [ { id: "msg_001", role: "user", content: "wrong project" }, ]); + db.close(); const result = await readOpencodeSession(projectRoot); expect(result.ok).toBe(false); @@ -169,22 +264,12 @@ describe("readOpencodeSession", () => { } }); - test("returns error when session has no message directory", async () => { + test("returns error when session has no messages", async () => { + const db = createDb(); const sessionId = `ses_${uniqueId}_nomsg`; - // Create session metadata but no message directory - const sessionDir = join(sessionBaseDir, projectHash); - mkdirSync(sessionDir, { recursive: true }); - createdDirs.push(sessionDir); - - const sessionMeta = { - id: sessionId, - path: projectRoot, - updated_at: new Date().toISOString(), - }; - writeFileSync( - join(sessionDir, `${sessionId}.json`), - JSON.stringify(sessionMeta) - ); + // Create session but no messages + makeSession(db, sessionId, projectRoot); + db.close(); const result = await readOpencodeSession(projectRoot); expect(result.ok).toBe(false); @@ -194,16 +279,19 @@ describe("readOpencodeSession", () => { }); test("respects maxEntries — keeps last N relevant entries", async () => { + const db = createDb(); const sessionId = `ses_${uniqueId}_limit`; - const messages: Array<{ id: string; role: string; content: string }> = []; + + const msgs: Array<{ id: string; role: string; content: string }> = []; for (let i = 0; i < 8; i++) { - messages.push({ + msgs.push({ id: `msg_${String(i).padStart(3, "0")}`, role: i % 2 === 0 ? "user" : "assistant", content: `msg ${i}`, }); } - makeSession(sessionId, projectRoot, messages); + makeSession(db, sessionId, projectRoot, msgs); + db.close(); const result = await readOpencodeSession(projectRoot, { maxEntries: 2 }); expect(result.ok).toBe(true); @@ -217,10 +305,12 @@ describe("readOpencodeSession", () => { }); test("truncates string content preview to 500 chars", async () => { + const db = createDb(); const sessionId = `ses_${uniqueId}_truncate`; - makeSession(sessionId, projectRoot, [ + makeSession(db, sessionId, projectRoot, [ { id: "msg_001", role: "user", content: "x".repeat(600) }, ]); + db.close(); const result = await readOpencodeSession(projectRoot); expect(result.ok).toBe(true); @@ -231,11 +321,129 @@ describe("readOpencodeSession", () => { expect(preview.endsWith("...")).toBe(true); }); - test("returns error when storage directory does not exist", async () => { - // readOpencodeSession should handle missing storage gracefully - const result = await readOpencodeSession( - "/nonexistent/path/that/wont/match" - ); + test("returns error when database does not exist", async () => { + // Point to a non-existent directory + Bun.env.XDG_DATA_HOME = join(tempDir, "nonexistent"); + + const result = await readOpencodeSession(projectRoot); expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("No opencode database found"); + } + }); + + test("skips synthetic parts", async () => { + const db = createDb(); + const sessionId = `ses_${uniqueId}_synthetic`; + const now = Date.now(); + + db.run( + "INSERT INTO session (id, directory, time_created, time_updated) VALUES (?, ?, ?, ?)", + [sessionId, projectRoot, now, now] + ); + + // User message with a synthetic part and a real part + const msgId = "msg_syn_001"; + db.run( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", + [msgId, sessionId, now + 1, now + 1, JSON.stringify({ role: "user" })] + ); + // Synthetic part (editor context) + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + [ + "prt_syn_001", + msgId, + sessionId, + now + 1, + now + 1, + JSON.stringify({ + type: "text", + text: "Note: The user opened the file", + synthetic: true, + }), + ] + ); + // Real part (user input) + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + [ + "prt_real_001", + msgId, + sessionId, + now + 2, + now + 2, + JSON.stringify({ type: "text", text: "actual user question" }), + ] + ); + db.close(); + + const result = await readOpencodeSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + expect(result.data.transcript[0]?.contentPreview).toBe( + "actual user question" + ); + // The synthetic part should not appear + expect(result.data.transcript[0]?.contentPreview).not.toContain( + "system-reminder" + ); + }); + + test("includes tool parts as [tool: name]", async () => { + const db = createDb(); + const sessionId = `ses_${uniqueId}_tools`; + const now = Date.now(); + + db.run( + "INSERT INTO session (id, directory, time_created, time_updated) VALUES (?, ?, ?, ?)", + [sessionId, projectRoot, now, now] + ); + + const msgId = "msg_tool_001"; + db.run( + "INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)", + [ + msgId, + sessionId, + now + 1, + now + 1, + JSON.stringify({ role: "assistant" }), + ] + ); + // Text part + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + [ + "prt_t1", + msgId, + sessionId, + now + 1, + now + 1, + JSON.stringify({ type: "text", text: "Let me check that." }), + ] + ); + // Tool part + db.run( + "INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?)", + [ + "prt_t2", + msgId, + sessionId, + now + 2, + now + 2, + JSON.stringify({ type: "tool", tool: "glob" }), + ] + ); + db.close(); + + const result = await readOpencodeSession(projectRoot); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error("expected ok"); + + const preview = result.data.transcript[0]?.contentPreview ?? ""; + expect(preview).toContain("Let me check that."); + expect(preview).toContain("[tool: glob]"); }); });