import { describe, it, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import Database from 'better-sqlite3'; import { OpenCodeAdapter } from '../src/engine/opencode-adapter.js'; // ─── Helpers ────────────────────────────────────────────────────────────────── /** * Create an ephemeral OpenCode-format SQLite DB under $XDG_DATA_HOME/opencode/ * and snapshot the original XDG_DATA_HOME so we can restore it after the test. * * Returns { restoreEnv, dbDir, dbPath } where restoreEnv restores XDG_DATA_HOME * and cleans up the temp directory. */ function withTestDb({ sessions = [], messages = [], parts = [] } = {}) { const tmpDir = mkdtempSync(join(tmpdir(), 'oc-adapter-test-')); const opencodeDir = join(tmpDir, 'opencode'); mkdirSync(opencodeDir, { recursive: true }); const dbPath = join(opencodeDir, 'opencode.db'); const db = new Database(dbPath, { timeout: 5000 }); db.exec(` CREATE TABLE IF NOT EXISTS session ( id TEXT PRIMARY KEY, parent_id TEXT, slug TEXT, directory TEXT, path TEXT, title TEXT, agent TEXT, model TEXT, version TEXT, cost REAL, tokens_input INTEGER, tokens_output INTEGER, tokens_reasoning INTEGER, tokens_cache_read INTEGER, tokens_cache_write INTEGER, time_created INTEGER, time_updated INTEGER, time_compacting INTEGER, time_archived INTEGER, summary_additions INTEGER, summary_deletions INTEGER, summary_files INTEGER, metadata TEXT ); CREATE TABLE IF NOT EXISTS message ( id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT ); CREATE TABLE IF NOT EXISTS part ( id TEXT PRIMARY KEY, message_id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT ); `); const insertSession = db.prepare(` INSERT INTO session (id, parent_id, slug, directory, path, title, agent, model, version, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, time_created, time_updated, time_compacting, time_archived, summary_additions, summary_deletions, summary_files, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); for (const s of sessions) { insertSession.run( s.id, s.parent_id ?? null, s.slug ?? '', s.directory ?? '', s.path ?? '', s.title ?? '', s.agent ?? '', s.model ?? '', s.version ?? '', s.cost ?? 0, s.tokens_input ?? 0, s.tokens_output ?? 0, s.tokens_reasoning ?? 0, s.tokens_cache_read ?? 0, s.tokens_cache_write ?? 0, s.time_created ?? 0, s.time_updated ?? 0, s.time_compacting ?? null, s.time_archived ?? null, s.summary_additions ?? 0, s.summary_deletions ?? 0, s.summary_files ?? 0, s.metadata ?? null ); } const insertMsg = db.prepare(` INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?) `); for (const m of messages) { insertMsg.run(m.id, m.session_id, m.time_created ?? 0, m.time_updated ?? 0, m.data ?? '{}'); } const insertPart = db.prepare(` INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?, ?) `); for (const p of parts) { insertPart.run(p.id, p.message_id, p.session_id, p.time_created ?? 0, p.time_updated ?? 0, p.data ?? '{}'); } db.close(); const origXdg = process.env.XDG_DATA_HOME; process.env.XDG_DATA_HOME = tmpDir; return { restoreEnv() { if (origXdg === undefined) { delete process.env.XDG_DATA_HOME; } else { process.env.XDG_DATA_HOME = origXdg; } try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} }, dbDir: tmpDir, dbPath, }; } // ─── Tests ──────────────────────────────────────────────────────────────────── describe('OpenCodeAdapter health state', () => { let env; afterEach(() => { if (env) env.restoreEnv(); }); it('reports session_source_available=true after successful empty-DB refresh', async () => { env = withTestDb({ sessions: [] }); const adapter = new OpenCodeAdapter(); await adapter.refresh(); const health = adapter.getHealthInfo(); assert.equal(health.session_source_available, true, 'empty DB with correct schema should report available'); assert.equal(health.session_count, 0); }); it('reports session_source_available=false when no DB file exists', async () => { const adapter = new OpenCodeAdapter(); // Mock _findDataDir so it sets null paths without falling through to the real DB mock.method(adapter, '_findDataDir', function () { this._dataDir = null; this._dbPath = null; this._reason = 'Mocked: no DB file'; }); await adapter.refresh(); const health = adapter.getHealthInfo(); assert.equal(health.session_source_available, false); assert.equal(health.session_count, 0); mock.restoreAll(); }); }); describe('OpenCodeAdapter production tool fixture', () => { let env; afterEach(() => { if (env) env.restoreEnv(); }); it('parses production type=tool parts and returns correct timeline and count', async () => { const sessionId = 'ses_prod_tool_test_001'; const msgId = 'msg_prod_001'; const now = Date.now(); env = withTestDb({ sessions: [{ id: sessionId, title: 'Production Tool Test', time_created: now - 60000, time_updated: now, tokens_input: 150, tokens_output: 300, }], messages: [{ id: msgId, session_id: sessionId, time_created: now - 50000, time_updated: now - 50000, data: JSON.stringify({ role: 'assistant' }), }], parts: [ { id: 'part_prod_001', message_id: msgId, session_id: sessionId, time_created: now - 50000, time_updated: now - 50000, data: JSON.stringify({ type: 'text', text: 'Let me search the codebase…' }), }, { id: 'part_prod_002', message_id: msgId, session_id: sessionId, time_created: now - 49000, time_updated: now - 49000, data: JSON.stringify({ type: 'tool', tool: 'codebaseSearch', state: { input: { query: 'findSessions' }, status: 'completed', output: 'Found 3 results\n- session1\n- session2\n- session3', }, }), }, { id: 'part_prod_003', message_id: msgId, session_id: sessionId, time_created: now - 48000, time_updated: now - 48000, data: JSON.stringify({ type: 'tool', tool: 'readFile', state: { input: { path: '/tmp/test.txt' }, status: 'completed', output: 'file content line 1\nfile content line 2', }, }), }, { id: 'part_prod_004', message_id: msgId, session_id: sessionId, time_created: now - 47000, time_updated: now - 47000, data: JSON.stringify({ type: 'text', text: 'Here are the results…' }), }, ], }); const adapter = new OpenCodeAdapter(); await adapter.refresh(); // Check session-level tool_call_count const session = adapter.getSession(sessionId); assert.ok(session, 'session should be found'); assert.equal(session.tool_call_count, 2, 'should count both tool parts'); assert.deepEqual(session.tool_calls, { codebaseSearch: 1, readFile: 1 }); // Check getMessages returns full timeline with correct ordering const messages = await adapter.getMessages(sessionId); assert.ok(Array.isArray(messages)); // Expected ordering: text("Let me search…"), tool_call(codebaseSearch), // tool_output(Fount 3 results…), tool_call(readFile), // tool_output("file content…"), text("Here are the results…") assert.equal(messages.length, 6, 'timeline: text + tool_call + tool_output + tool_call + tool_output + text'); // Verify types and content in order assert.equal(messages[0].type, 'assistant_message'); assert.ok(messages[0].message.includes('Let me search')); assert.equal(messages[1].type, 'tool_call'); assert.equal(messages[1].name, 'codebaseSearch'); assert.equal(messages[2].type, 'tool_output'); assert.ok(messages[2].output_preview.includes('Found 3 results')); assert.equal(messages[3].type, 'tool_call'); assert.equal(messages[3].name, 'readFile'); assert.equal(messages[4].type, 'tool_output'); assert.ok(messages[4].output_preview.includes('file content')); assert.equal(messages[5].type, 'assistant_message'); assert.ok(messages[5].message.includes('results')); }); }); describe('OpenCodeAdapter session filtering (child/archived)', () => { let env; afterEach(() => { if (env) env.restoreEnv(); }); it('getSession returns null for child sessions', async () => { const parentId = 'ses_parent_001'; const childId = 'ses_child_001'; const now = Date.now(); env = withTestDb({ sessions: [ { id: parentId, parent_id: null, title: 'Parent', time_created: now - 100000, time_updated: now, }, { id: childId, parent_id: parentId, title: 'Child', time_created: now - 50000, time_updated: now, }, ], }); const adapter = new OpenCodeAdapter(); await adapter.refresh(); // After refresh, only parent should be in the sessions list assert.ok(adapter.getSession(parentId), 'parent session should be found'); assert.equal(adapter.getSession(childId), null, 'child session should not be found in list'); // Fallback getSession also rejects children (direct DB lookup filters parent_id) const fromDb = adapter.getSession(childId); assert.equal(fromDb, null, 'getSession fallback should reject child'); }); it('getSession returns null for archived sessions', async () => { const activeId = 'ses_active_001'; const archivedId = 'ses_archived_001'; const now = Date.now(); env = withTestDb({ sessions: [ { id: activeId, parent_id: null, time_archived: null, title: 'Active', time_created: now - 100000, time_updated: now, }, { id: archivedId, parent_id: null, time_archived: now - 10000, title: 'Archived', time_created: now - 200000, time_updated: now - 10000, }, ], }); const adapter = new OpenCodeAdapter(); await adapter.refresh(); assert.ok(adapter.getSession(activeId), 'active session should be found'); assert.equal(adapter.getSession(archivedId), null, 'archived session should not be found'); }); }); describe('OpenCodeAdapter CLI-based deletion (isolated, no real CLI)', () => { let env; afterEach(() => { if (env) env.restoreEnv(); }); it('can_delete is false when CLI is unavailable', async () => { const now = Date.now(); env = withTestDb({ sessions: [{ id: 'ses_del_test_001', title: 'Delete Test', time_created: now - 60000, time_updated: now, }], }); const adapter = new OpenCodeAdapter(); // Mock _detectCli so it does not find the CLI mock.method(adapter, '_detectCli', function () { this.cliAvailable = false; this._reason = 'Mocked: no CLI'; }); await adapter.refresh(); const session = adapter.getSession('ses_del_test_001'); assert.ok(session); assert.equal(session.can_delete, false, 'can_delete should be false when CLI is unavailable'); mock.restoreAll(); }); });