mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
* feat: add codex-worktree session adapter Adds the third session adapter (after dmux-tmux and claude-history), normalizing Codex rollout sessions into the harness-neutral ecc.session.v1 snapshot. Reads ~/.codex/sessions rollout JSONL, derives objective (skipping the AGENTS.md preamble + leading message UUID), model, originator, worktree cwd, and best-effort git branch. This is step 1 of ECC-2.0-SESSION-ADAPTER-DISCOVERY (move the abstraction beyond tmux + Claude-history) and supports the wrap/adapt control-pane strategy: ECC reads sessions from any harness rather than owning one UX. - scripts/lib/session-adapters/codex-worktree.js: adapter + rollout parser - canonical-session.js: normalizeCodexWorktreeSession - registry.js: register adapter, codex/codex-worktree target types - tests/lib/session-adapters-codex.test.js: 4 tests (unit + registry routing) * 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). * test: cover error/fallback branches for codex-worktree + opencode adapters Lift global branch coverage past the 80% gate (was 79.53%). Adds error and fallback path tests: missing-session/unknown-id throws, findRolloutById/ findSessionInfoById, direct file targets, objective truncation, model fallbacks, corrupt-line skip, mtime activity fallback, and the real resolveGitBranch path outside a repo. codex-worktree.js branch 52.8%->78.3%; global branch 80.04%.
220 lines
9.7 KiB
JavaScript
220 lines
9.7 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
|
|
const {
|
|
createOpencodeAdapter,
|
|
parseOpencodeTarget,
|
|
parseOpencodeSession,
|
|
isOpencodeSessionFileTarget,
|
|
findLatestSessionInfo,
|
|
findSessionInfoById
|
|
} = 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');
|
|
});
|
|
|
|
|
|
// --- branch/error coverage ---
|
|
|
|
function writeSession(storageDir, projectHash, sessionId, info, messages) {
|
|
const sdir = path.join(storageDir, 'session', projectHash);
|
|
const mdir = path.join(storageDir, 'message', sessionId);
|
|
fs.mkdirSync(sdir, { recursive: true });
|
|
fs.mkdirSync(mdir, { recursive: true });
|
|
fs.writeFileSync(path.join(sdir, sessionId + '.json'), JSON.stringify(info), 'utf8');
|
|
(messages || []).forEach((m, i) => fs.writeFileSync(path.join(mdir, 'msg_' + i + '.json'), JSON.stringify(m), 'utf8'));
|
|
return path.join(sdir, sessionId + '.json');
|
|
}
|
|
|
|
test('parseOpencodeTarget handles non-string and unprefixed input', () => {
|
|
assert.strictEqual(parseOpencodeTarget(null), null);
|
|
assert.strictEqual(parseOpencodeTarget('/abs/ses_x.json'), null);
|
|
});
|
|
|
|
test('adapter throws for empty store and unknown id; findLatest on empty => null', () => {
|
|
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-empty-'));
|
|
assert.strictEqual(findLatestSessionInfo(storageDir), null);
|
|
const adapter = createOpencodeAdapter({ storageDir, loadStateStoreImpl: () => null });
|
|
assert.throws(() => adapter.open('opencode:latest', { cwd: os.tmpdir() }).getSnapshot(), /No OpenCode sessions found/);
|
|
assert.throws(() => adapter.open('opencode:ses_missing', { cwd: os.tmpdir() }).getSnapshot(), /not found/);
|
|
});
|
|
|
|
test('findSessionInfoById + direct file target + isOpencodeSessionFileTarget', () => {
|
|
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-byid-'));
|
|
const now = Date.now();
|
|
const fp = writeSession(storageDir, 'projhash', 'ses_UNIQUE001', {
|
|
id: 'ses_UNIQUE001', directory: storageDir, title: 'real title', time: { created: now - 5000, updated: now - 5000 }
|
|
}, []);
|
|
|
|
assert.strictEqual(findSessionInfoById(storageDir, 'ses_UNIQUE001'), fp);
|
|
assert.ok(isOpencodeSessionFileTarget(fp, os.tmpdir()));
|
|
assert.ok(!isOpencodeSessionFileTarget('/tmp/not-session.json', os.tmpdir()));
|
|
|
|
const adapter = createOpencodeAdapter({ storageDir, loadStateStoreImpl: () => null, resolveBranchImpl: () => null });
|
|
const byId = adapter.open('opencode:ses_UNIQUE001', { cwd: os.tmpdir() }).getSnapshot();
|
|
assert.strictEqual(byId.session.id, 'ses_UNIQUE001');
|
|
const byFile = adapter.open(fp, { cwd: os.tmpdir() }).getSnapshot();
|
|
assert.strictEqual(byFile.session.id, 'ses_UNIQUE001');
|
|
});
|
|
|
|
test('parseOpencodeSession: model from later assistant message, missing-time => recorded', () => {
|
|
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-parse-'));
|
|
const fp = writeSession(storageDir, 'ph', 'ses_MODEL01', {
|
|
id: 'ses_MODEL01', directory: storageDir, title: 'do work'
|
|
// no time block => updatedMs null => recorded/inactive
|
|
}, [
|
|
{ id: 'm0', role: 'user' },
|
|
{ id: 'm1', role: 'assistant', modelID: 'claude-sonnet-4-5-20250929', providerID: 'anthropic' }
|
|
]);
|
|
const parsed = parseOpencodeSession(fp, { storageDir, resolveBranchImpl: () => null });
|
|
assert.strictEqual(parsed.model, 'claude-sonnet-4-5-20250929');
|
|
assert.strictEqual(parsed.provider, 'anthropic');
|
|
assert.strictEqual(parsed.active, false);
|
|
});
|
|
|
|
test('resolveGitBranch real path returns null outside a repo', () => {
|
|
const storageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-oc-nogit-'));
|
|
const fp = writeSession(storageDir, 'ph', 'ses_NOGIT01', {
|
|
id: 'ses_NOGIT01', directory: storageDir, title: 't', time: { created: 1, updated: 1 }
|
|
}, []);
|
|
const parsed = parseOpencodeSession(fp, { storageDir }); // no resolveBranchImpl => real git path
|
|
assert.strictEqual(parsed.branch, null);
|
|
});
|
|
|
|
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`);
|
|
if (failed > 0) process.exit(1);
|