Files
everything-claude-code/tests/lib/session-adapters-opencode.test.js
Affaan Mustafa f4af79ace4 feat: add opencode session adapter + allow empty intent objective
Adds the fourth session adapter (after dmux-tmux, claude-history,
codex-worktree), normalizing OpenCode sessions into ecc.session.v1.

Reads ~/.local/share/opencode/storage: session/<project>/ses_*.json
for metadata (id, directory, title, version, projectID, time) and
message/<session>/msg_*.json to extract the model (modelID/providerID
from the first assistant message). Derives objective from the session
title, treating the auto-generated "New session - <date>" title as no
objective. Recency-based active/recorded state.

Schema: relax intent.objective from non-empty to allow empty string
(ensureStringAllowEmpty). Sessions legitimately have no objective yet
(fresh/auto-titled), and claude-history already emitted "" via
metadata.title fallback. This fixes a latent over-strict validation.

- scripts/lib/session-adapters/opencode.js: adapter + storage parser
- canonical-session.js: normalizeOpencodeSession + ensureStringAllowEmpty
- registry.js: register adapter + opencode target type
- tests/lib/session-adapters-opencode.test.js: 5 tests

Tests: opencode 5/0, codex 4/0, session-adapters 14/0,
control-pane-state 10/0, session-inspect 8/0, control-pane 12/0.
Smoke-tested on a real OpenCode session (140 messages, gpt-5.3-codex).
2026-06-04 16:51:25 -04:00

149 lines
6.1 KiB
JavaScript

'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
createOpencodeAdapter,
parseOpencodeTarget,
findLatestSessionInfo
} = require('../../scripts/lib/session-adapters/opencode');
const {
normalizeOpencodeSession,
validateCanonicalSnapshot
} = require('../../scripts/lib/session-adapters/canonical-session');
const { createAdapterRegistry } = require('../../scripts/lib/session-adapters/registry');
console.log('=== Testing opencode session adapter ===\n');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ok - ${name}`);
} catch (error) {
failed += 1;
console.log(` FAIL - ${name}`);
console.log(` ${error && error.message}`);
}
}
function writeOpencodeFixture({ title = 'rebuild the basket trader rebalancer', updatedAgoMs = 0 } = {}) {
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-store-'));
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-repo-'));
const projectHash = 'b43c6d2f5bbf6e71bc3d139c1656bf3afe1935aa';
const sessionId = 'ses_66d5468bdffeVlx1Hy2KkdIshB';
const sessionDir = path.join(storageDir, 'session', projectHash);
const messageDir = path.join(storageDir, 'message', sessionId);
fs.mkdirSync(sessionDir, { recursive: true });
fs.mkdirSync(messageDir, { recursive: true });
const updated = Date.now() - updatedAgoMs;
fs.writeFileSync(path.join(sessionDir, `${sessionId}.json`), JSON.stringify({
id: sessionId,
version: '0.12.1',
projectID: projectHash,
directory: repoRoot,
title,
time: { created: updated - 10000, updated }
}), 'utf8');
// one user message + one assistant message carrying the model
fs.writeFileSync(path.join(messageDir, 'msg_user01.json'), JSON.stringify({
id: 'msg_user01', sessionID: sessionId, role: 'user', time: { created: updated - 9000 }
}), 'utf8');
fs.writeFileSync(path.join(messageDir, 'msg_asst01.json'), JSON.stringify({
id: 'msg_asst01', sessionID: sessionId, role: 'assistant',
time: { created: updated - 8000, completed: updated - 7000 },
modelID: 'claude-sonnet-4-5-20250929', providerID: 'anthropic'
}), 'utf8');
return { storageDir, repoRoot, sessionId, sessionInfoPath: path.join(sessionDir, `${sessionId}.json`) };
}
test('normalizeOpencodeSession produces a valid ecc.session.v1 snapshot', () => {
const snapshot = normalizeOpencodeSession({
sessionId: 'ses_x', sessionPath: '/tmp/s.json', cwd: '/repo', branch: 'main',
objective: 'do the thing', title: 'do the thing', model: 'claude-sonnet-4-5-20250929',
provider: 'anthropic', version: '0.12.1', projectId: 'proj', createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:05:00Z', messageCount: 2, active: false
}, { type: 'opencode', value: 'ses_x' });
validateCanonicalSnapshot(snapshot);
assert.strictEqual(snapshot.adapterId, 'opencode');
assert.strictEqual(snapshot.session.kind, 'opencode');
assert.strictEqual(snapshot.workers[0].runtime.kind, 'opencode-session');
assert.strictEqual(snapshot.workers[0].artifacts.provider, 'anthropic');
});
test('parseOpencodeTarget strips the opencode prefix', () => {
assert.strictEqual(parseOpencodeTarget('opencode:latest'), 'latest');
assert.strictEqual(parseOpencodeTarget('opencode:ses_abc'), 'ses_abc');
assert.strictEqual(parseOpencodeTarget('codex:latest'), null);
});
test('adapter reads latest session, extracts model from messages, derives objective from title', () => {
const { storageDir, repoRoot, sessionInfoPath } = writeOpencodeFixture({ updatedAgoMs: 0 });
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-rec-'));
assert.strictEqual(findLatestSessionInfo(storageDir), sessionInfoPath);
const adapter = createOpencodeAdapter({
storageDir, recordingDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => 'main'
});
const snapshot = adapter.open('opencode:latest', { cwd: repoRoot }).getSnapshot();
assert.strictEqual(snapshot.adapterId, 'opencode');
assert.strictEqual(snapshot.session.state, 'active');
assert.strictEqual(snapshot.workers[0].worktree, repoRoot);
assert.strictEqual(snapshot.workers[0].branch, 'main');
assert.strictEqual(snapshot.workers[0].artifacts.model, 'claude-sonnet-4-5-20250929');
assert.strictEqual(snapshot.workers[0].artifacts.provider, 'anthropic');
assert.strictEqual(snapshot.workers[0].artifacts.messageCount, 2);
assert.strictEqual(snapshot.workers[0].intent.objective, 'rebuild the basket trader rebalancer');
});
test('auto-title "New session - ..." yields empty objective; stale session is recorded', () => {
const { storageDir, repoRoot } = writeOpencodeFixture({
title: 'New session - 2025-09-28T23:32:22.978Z',
updatedAgoMs: 60 * 60 * 1000
});
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-rec2-'));
const adapter = createOpencodeAdapter({
storageDir, recordingDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null
});
const snapshot = adapter.open('opencode:latest', { cwd: repoRoot }).getSnapshot();
assert.strictEqual(snapshot.workers[0].intent.objective, '');
assert.strictEqual(snapshot.session.state, 'recorded');
assert.strictEqual(snapshot.workers[0].runtime.dead, true);
});
test('registry routes structured opencode target and lists the adapter', () => {
const { storageDir, repoRoot } = writeOpencodeFixture();
const recordingDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-opencode-reg-'));
const registry = createAdapterRegistry({
recordingDir,
loadStateStoreImpl: () => null,
adapterOptions: { opencode: { storageDir, resolveBranchImpl: () => null } }
});
const typed = registry.open({ type: 'opencode', value: 'latest' }, { cwd: repoRoot }).getSnapshot();
assert.strictEqual(typed.adapterId, 'opencode');
const listed = registry.listAdapters().map(a => a.id);
assert.ok(listed.includes('opencode'), 'registry lists opencode adapter');
assert.ok(listed.includes('codex-worktree'), 'registry still lists codex-worktree adapter');
});
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
if (failed > 0) process.exit(1);