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]"); }); });