feat: add OpenCode session management

This commit is contained in:
liujing
2026-07-16 21:13:11 +08:00
parent 535b03712e
commit 0015774cd4
11 changed files with 1101 additions and 18 deletions
+2
View File
@@ -1,11 +1,13 @@
import { CodexAdapter } from './codex-adapter.js';
import { ClaudeAdapter } from './claude-adapter.js';
import { AgyAdapter } from './agy-adapter.js';
import { OpenCodeAdapter } from './opencode-adapter.js';
const ENGINES = {
codex: new CodexAdapter(),
claude: new ClaudeAdapter(),
agy: new AgyAdapter(),
opencode: new OpenCodeAdapter(),
};
export function getEngine(name) {
+486
View File
@@ -0,0 +1,486 @@
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import Database from 'better-sqlite3';
import { BaseEngineAdapter } from './base.js';
const DEFAULT_DATA_DIR = join(homedir(), '.local', 'share', 'opencode');
export class OpenCodeAdapter extends BaseEngineAdapter {
constructor() {
super('opencode', 'OpenCode');
this._dataDir = null;
this._dbPath = null;
this._reason = '';
this._db = null;
this.cliAvailable = false;
this._dbReadable = false;
this._sessionsLoaded = false;
this._messageCache = new Map();
}
async checkHealth() {
this._detectCli();
this._findDataDir();
return this.getHealthInfo();
}
async refresh() {
this._detectCli();
this._findDataDir();
this.lastRefresh = Date.now();
this._messageCache.clear();
this.error = null; // clear stale errors on successful refresh (codex-1-2)
if (!this._dataDir || !this._dbPath) {
this._sessions = [];
this._sessionsMap.clear();
this.available = false;
this._sessionsLoaded = false;
return this.getHealthInfo();
}
try {
this._openDb();
this._readSessions();
this._sessionsLoaded = true;
// available = DB is readable, regardless of CLI or session count (codex-1-2)
this.available = true;
} catch (err) {
this.error = err.message;
console.error('[OpenCodeAdapter] Refresh error:', err.message);
this._sessions = [];
this._sessionsMap.clear();
this.available = false;
this._sessionsLoaded = false;
} finally {
this._closeDb();
}
return this.getHealthInfo();
}
_detectCli() {
try {
const result = execSync('which opencode 2>/dev/null || echo ""', { encoding: 'utf-8', timeout: 3000 }).trim();
if (result) {
this.cliAvailable = true;
this._reason = '';
return;
}
} catch {}
this.cliAvailable = false;
this._reason = '未检测到 opencode CLI';
}
_findDataDir() {
// 1. Honor XDG_DATA_HOME/opencode first (codex-1-2)
const xdgDataHome = process.env.XDG_DATA_HOME;
if (xdgDataHome) {
const xdgDir = join(xdgDataHome, 'opencode');
const xdgDbPath = join(xdgDir, 'opencode.db');
if (existsSync(xdgDbPath)) {
this._dataDir = xdgDir;
this._dbPath = xdgDbPath;
this._reason = '';
return;
}
}
// 2. Try opencode debug paths (CLI) if CLI is available
try {
const output = execSync('opencode debug paths 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
for (const line of output.split('\n')) {
const trimmed = line.trim();
if (trimmed.startsWith('data')) {
const parts = trimmed.split(/\s+/);
if (parts.length >= 2) {
const dataDir = parts[1];
const dbPath = join(dataDir, 'opencode.db');
if (existsSync(dbPath)) {
this._dataDir = dataDir;
this._dbPath = dbPath;
this._reason = '';
return;
}
}
}
}
} catch {}
// 3. Fallback to ~/.local/share/opencode
const dbPath = join(DEFAULT_DATA_DIR, 'opencode.db');
if (existsSync(dbPath)) {
this._dataDir = DEFAULT_DATA_DIR;
this._dbPath = dbPath;
this._reason = '';
return;
}
this._dataDir = null;
this._dbPath = null;
this._reason = `未找到 OpenCode 数据库: ${dbPath}`;
}
_openDb() {
this._closeDb();
if (!this._dbPath) return;
try {
this._db = new Database(this._dbPath, { readonly: true, fileMustExist: true, timeout: 10000 });
this._db.pragma('query_only = true');
this._dbReadable = true;
} catch (err) {
this._db = null;
this._dbReadable = false;
this._reason = `无法打开数据库: ${err.message}`;
throw err;
}
}
_closeDb() {
if (this._db) {
try { this._db.close(); } catch {}
this._db = null;
}
this._dbReadable = false;
}
_readSessions() {
this._sessions = [];
this._sessionsMap.clear();
if (!this._db) return;
const rows = this._db.prepare(`
SELECT 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,
parent_id, metadata
FROM session
WHERE parent_id IS NULL AND time_archived IS NULL
ORDER BY time_created DESC
`).all();
for (const row of rows) {
const id = row.id;
if (!id) continue;
let modelName = '';
const modelStr = row.model || '';
try {
const modelObj = JSON.parse(modelStr);
modelName = modelObj.id || modelStr;
} catch {
modelName = modelStr;
}
const created = Number(row.time_created);
const updated = Number(row.time_updated);
const now = Date.now();
let status = 'success';
if (updated && (now - updated) < 300000) {
status = 'active';
} else if (created && updated && (updated - created) < 5000 && !row.tokens_input && !row.tokens_output) {
status = 'interrupted';
}
const entry = {
key: `opencode:${id}`,
engine: 'opencode',
engine_label: 'OpenCode',
id,
can_delete: this.cliAvailable,
summary: row.title || row.slug || '',
cwd: row.directory || '',
model: modelName,
agent: row.agent || '',
cli_version: row.version || '',
source: 'cli',
created_at: created,
updated_at: updated,
duration_ms: created && updated ? updated - created : null,
status,
tool_call_count: 0,
tool_calls: {},
message_count: 0,
tokens_input: row.tokens_input || 0,
tokens_output: row.tokens_output || 0,
tokens_reasoning: row.tokens_reasoning || 0,
tokens_cache_read: row.tokens_cache_read || 0,
tokens_cache_write: row.tokens_cache_write || 0,
cost: row.cost || 0,
summary_additions: row.summary_additions || 0,
summary_deletions: row.summary_deletions || 0,
summary_files: row.summary_files || 0,
parent_id: row.parent_id,
time_archived: row.time_archived,
resume_command: this.buildResumeCommand(id),
can_resume: true,
};
this._sessions.push(entry);
this._sessionsMap.set(id, entry);
}
// Load tool usage and message counts from the part table (codex-1-1)
this._loadMessageStats();
}
/** Count tool calls and messages per session from the part table (codex-2-1) */
_loadMessageStats() {
if (!this._db || !this._sessions.length) return;
for (const session of this._sessions) {
try {
// JOIN message (for role) with part (for content)
const rows = this._db.prepare(`
SELECT m.id AS msg_id, m.data AS msg_data, p.data AS part_data
FROM message m
LEFT JOIN part p ON p.message_id = m.id
WHERE m.session_id = ?
ORDER BY m.time_created ASC, p.time_created ASC
`).all(session.id);
const toolCalls = {};
const seenMessages = new Set();
let userMsgCount = 0;
let asstMsgCount = 0;
for (const row of rows) {
// Count distinct messages by role (use msg_id to deduplicate)
if (row.msg_data && !seenMessages.has(row.msg_id)) {
try {
const parsed = JSON.parse(row.msg_data);
if (parsed.role === 'user') userMsgCount++;
else if (parsed.role === 'assistant') asstMsgCount++;
seenMessages.add(row.msg_id);
} catch {
// malformed JSON — skip (codex-2-1)
}
}
// Count tool parts by tool name (production: type=tool, test-only: type=tool_use)
if (row.part_data) {
try {
const part = JSON.parse(row.part_data);
// Production OpenCode uses type='tool' with {tool, state};
// test fixtures may use legacy type='tool_use' with {name}.
// Both are valid part types; backward compatible check (codex-2-1)
if (part.type === 'tool' && part.tool) {
toolCalls[part.tool] = (toolCalls[part.tool] || 0) + 1;
if (part.state && part.state.output) {
// also count completed tool_output entries — count them once per part
}
} else if (part.type === 'tool_use' && part.name) {
toolCalls[part.name] = (toolCalls[part.name] || 0) + 1;
}
} catch {
// malformed JSON — skip (codex-2-1)
}
}
}
session.tool_call_count = Object.values(toolCalls).reduce((a, b) => a + b, 0);
session.tool_calls = toolCalls;
session.message_count = userMsgCount + asstMsgCount;
} catch (err) {
console.error(`[OpenCodeAdapter] _loadMessageStats error for ${String(session.id).slice(0, 16)}:`, err.message);
}
}
}
/** Get a single session by ID (cache first, then DB) */
getSession(id) {
// Check cache first
const cached = this._sessionsMap.get(id);
if (cached) return cached;
// Fallback: load from DB directly
if (!this._dbPath) return null;
let db = null;
try {
db = new Database(this._dbPath, { readonly: true, fileMustExist: true, timeout: 10000 });
db.pragma('query_only = true');
const row = db.prepare(`
SELECT 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,
parent_id, metadata
FROM session WHERE id = ?
`).get(id);
if (!row) return null;
// Reject child or archived sessions (codex-2-3)
if (row.parent_id !== null || row.time_archived !== null) return null;
let modelName = '';
const modelStr = row.model || '';
try {
const modelObj = JSON.parse(modelStr);
modelName = modelObj.id || modelStr;
} catch {
modelName = modelStr;
}
const created = Number(row.time_created);
const updated = Number(row.time_updated);
const now = Date.now();
let status = 'success';
if (updated && (now - updated) < 300000) {
status = 'active';
} else if (created && updated && (updated - created) < 5000 && !row.tokens_input && !row.tokens_output) {
status = 'interrupted';
}
return {
key: `opencode:${id}`,
engine: 'opencode',
engine_label: 'OpenCode',
id,
can_delete: this.cliAvailable,
summary: row.title || row.slug || '',
cwd: row.directory || '',
model: modelName,
agent: row.agent || '',
cli_version: row.version || '',
source: 'cli',
created_at: created,
updated_at: updated,
duration_ms: created && updated ? updated - created : null,
status,
tool_call_count: 0,
tool_calls: {},
message_count: 0,
tokens_input: row.tokens_input || 0,
tokens_output: row.tokens_output || 0,
tokens_reasoning: row.tokens_reasoning || 0,
tokens_cache_read: row.tokens_cache_read || 0,
tokens_cache_write: row.tokens_cache_write || 0,
cost: row.cost || 0,
summary_additions: row.summary_additions || 0,
summary_deletions: row.summary_deletions || 0,
summary_files: row.summary_files || 0,
parent_id: row.parent_id,
time_archived: row.time_archived,
resume_command: this.buildResumeCommand(id),
can_resume: true,
};
} catch (err) {
console.error(`[OpenCodeAdapter] getSession error for ${String(id).slice(0, 16)}:`, err.message);
return null;
} finally {
if (db) { try { db.close(); } catch {} }
}
}
/** Get messages for a session — reads from part table (codex-1-1) */
async getMessages(id) {
if (this._messageCache.has(id)) {
return this._messageCache.get(id);
}
if (!this._dbPath) return [];
let db = null;
try {
db = new Database(this._dbPath, { readonly: true, fileMustExist: true, timeout: 10000 });
db.pragma('query_only = true');
// Join message (for role) with part (for content)
const rows = db.prepare(`
SELECT m.data AS msg_data, m.time_created AS msg_time, p.data AS part_data
FROM message m
LEFT JOIN part p ON p.message_id = m.id
WHERE m.session_id = ?
ORDER BY m.time_created ASC, p.time_created ASC
LIMIT 300
`).all(id);
const messages = [];
for (const row of rows) {
if (!row.msg_data || !row.part_data) continue;
try {
const msgData = JSON.parse(row.msg_data);
const role = msgData.role || '';
const partData = JSON.parse(row.part_data);
const type = partData.type || '';
const timestamp = Number(row.msg_time) || 0;
if (type === 'text' && partData.text) {
messages.push({
type: role === 'assistant' ? 'assistant_message' : 'user_message',
message: partData.text,
time: timestamp,
});
} else if (type === 'tool' && partData.tool) {
// Production OpenCode type=tool with tool name and state (codex-2-1)
messages.push({
type: 'tool_call',
name: partData.tool,
arguments: partData.state?.input ? JSON.stringify(partData.state.input, null, 2) : '',
time: timestamp,
});
if (partData.state?.status === 'completed' && partData.state?.output) {
messages.push({
type: 'tool_output',
output_preview: (typeof partData.state.output === 'string'
? partData.state.output
: JSON.stringify(partData.state.output)).substring(0, 500),
time: timestamp,
});
}
} else if (type === 'tool_use' && partData.name) {
messages.push({
type: 'tool_call',
name: partData.name,
arguments: partData.input ? JSON.stringify(partData.input, null, 2) : '',
time: timestamp,
});
} else if (type === 'tool_result') {
const output = typeof partData.content === 'string'
? partData.content
: partData.content ? JSON.stringify(partData.content) : '';
messages.push({
type: 'tool_output',
output_preview: output.substring(0, 500),
time: timestamp,
});
}
// Skip reasoning, step — not rendered
} catch {
// malformed JSON — skip
}
}
this._messageCache.set(id, messages);
return messages;
} catch (err) {
console.error(`[OpenCodeAdapter] getMessages error for ${String(id).slice(0, 16)}:`, err.message);
return [];
} finally {
if (db) { try { db.close(); } catch {} }
}
}
buildResumeCommand(id) {
return `opencode --session ${id}`;
}
getHealthInfo() {
return {
...super.getHealthInfo(),
cli_available: this.cliAvailable,
session_source_available: this._sessionsLoaded,
data_dir: this._dataDir,
db_path: this._dbPath,
reason: this._reason,
};
}
}
+62 -2
View File
@@ -260,7 +260,7 @@ app.post('/api/refresh', async (req, res) => {
});
// ─── Custom Name ──────────────────────────────────────────────────
const VALID_ENGINES = ['codex', 'claude', 'agy'];
const VALID_ENGINES = ['codex', 'claude', 'agy', 'opencode'];
app.put('/api/sessions/:engine/:id/custom-name', (req, res) => {
try {
@@ -316,7 +316,7 @@ app.delete('/api/sessions/:engine/:id/custom-name', (req, res) => {
app.post('/api/sessions/:engine/:id/generate-title', async (req, res) => {
try {
const { engine, id } = req.params;
if (!['codex', 'claude', 'agy'].includes(engine)) {
if (!['codex', 'claude', 'agy', 'opencode'].includes(engine)) {
return res.status(400).json({ error: `Invalid engine: ${engine}` });
}
const e = getEngine(engine);
@@ -345,6 +345,66 @@ app.post('/api/sessions/:engine/:id/generate-title', async (req, res) => {
}
});
// ─── Session Delete (OpenCode only) ──────────────────────────────
app.delete('/api/sessions/:engine/:id', async (req, res) => {
try {
const { engine, id } = req.params;
if (engine !== 'opencode') {
return res.status(400).json({ error: '原始会话删除仅支持 OpenCode 引擎' });
}
const e = getEngine(engine);
if (!e || !e.cliAvailable) {
return res.status(503).json({ error: 'OpenCode CLI 不可用,无法删除' });
}
// Re-verify session is top-level and unarchived (codex-1-4)
const session = e.getSession(id);
if (!session) {
return res.status(404).json({ error: '会话不存在' });
}
if (session.parent_id) {
return res.status(400).json({ error: '只能删除顶级会话,子会话跟随父会话删除' });
}
if (session.time_archived) {
return res.status(400).json({ error: '已归档会话不可直接删除,请先取消归档' });
}
// Execute delete via opencode CLI — no shell, arg array (codex-1-4)
const { spawnSync } = await import('child_process');
const result = spawnSync('opencode', ['session', 'delete', id], {
stdio: 'pipe',
encoding: 'utf-8',
timeout: 30000,
shell: false,
});
if (result.error) {
return res.status(500).json({ error: `删除失败: ${result.error.message}` });
}
if (result.signal) {
return res.status(500).json({ error: `删除进程被中断: ${result.signal}` });
}
if (result.status !== 0) {
const errMsg = (result.stderr || result.stdout || '').trim() || '删除失败';
return res.status(500).json({ error: `OpenCode 返回错误: ${errMsg}` });
}
// Clean up custom name metadata
try {
deleteCustomName(engine, id);
} catch {}
// Trigger refresh
await refreshEngines(['opencode']);
res.json({ status: 'ok' });
} catch (err) {
console.error('[API] session delete error:', err);
res.status(500).json({ error: err.message });
}
});
// ─── SPA fallback ──────────────────────────────────────────────────
if (existsSync(frontendDist)) {
app.get('*', (req, res) => {
+14
View File
@@ -155,12 +155,26 @@ function fetchAgyMessages(session) {
return result;
}
/** Fetch OpenCode messages via adapter (reuses parsed message format). */
async function fetchOpenCodeMessages(sessionId, adapter) {
try {
const messages = await adapter.getMessages(sessionId);
return messages
.filter(m => m.type === 'user_message' || m.type === 'assistant_message')
.map(m => ({ type: m.type, message: (m.message || '').slice(0, 2000) }));
} catch (err) {
console.warn(`[TitleGen] OpenCode fetch error for ${sessionId.slice(0, 16)}:`, err.message);
return [];
}
}
async function fetchMessages(engine, sessionId, adapter) {
const session = adapter ? adapter.getSession(sessionId) : null;
if (!session) return [];
if (engine === 'codex') return await fetchCodexMessages(session);
if (engine === 'claude') return await fetchClaudeMessages(sessionId);
if (engine === 'agy') return fetchAgyMessages(session);
if (engine === 'opencode') return await fetchOpenCodeMessages(sessionId, adapter);
return [];
}
+381
View File
@@ -0,0 +1,381 @@
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();
});
});