Files
agent-session-manager/backend/test/opencode-adapter.test.js
T

382 lines
12 KiB
JavaScript

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();
});
});