import { describe, it, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { ReasonixAdapter } from '../src/engine/reasonix-adapter.js'; function withTestFiles({ projects = {}, filesOutside = [] } = {}) { const tmpDir = mkdtempSync(join(tmpdir(), 'rx-adapter-test-')); const projectsDir = join(tmpDir, 'projects'); mkdirSync(projectsDir, { recursive: true }); for (const [projName, sessions] of Object.entries(projects)) { const projPath = join(projectsDir, projName); const sessionsPath = join(projPath, 'sessions'); mkdirSync(sessionsPath, { recursive: true }); for (const s of sessions) { const id = s.id; const metaPath = join(sessionsPath, `${id}.jsonl.meta`); const jsonlPath = join(sessionsPath, `${id}.jsonl`); if (s.metaContent !== undefined) { writeFileSync(metaPath, s.metaContent, 'utf8'); } else if (s.meta !== undefined) { writeFileSync(metaPath, JSON.stringify(s.meta), 'utf8'); } if (s.jsonlContent !== undefined) { writeFileSync(jsonlPath, s.jsonlContent, 'utf8'); } else if (s.jsonl !== undefined) { writeFileSync(jsonlPath, s.jsonl, 'utf8'); } if (s.hasLock) { writeFileSync(join(sessionsPath, `${id}.jsonl.lock`), '', 'utf8'); } } } for (const f of filesOutside) { const fullPath = join(tmpDir, f.path); mkdirSync(join(fullPath, '..'), { recursive: true }); writeFileSync(fullPath, f.content || '', 'utf8'); } const origHome = process.env.REASONIX_HOME; process.env.REASONIX_HOME = tmpDir; return { restoreEnv() { if (origHome === undefined) { delete process.env.REASONIX_HOME; } else { process.env.REASONIX_HOME = origHome; } try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} }, tmpDir, }; } describe('ReasonixAdapter health & CLI scenarios', () => { let env; afterEach(() => { mock.restoreAll(); if (env) env.restoreEnv(); }); it('reports source available even if CLI is absent', async () => { env = withTestFiles({ projects: { 'test-proj': [ { id: 'session-1', meta: { id: 'session-1', created_at: '2026-07-16T12:00:00Z', model: 'gpt-4' }, jsonl: '{"role":"user","content":"hi"}\n', }, ], }, }); const adapter = new ReasonixAdapter(); mock.method(adapter, '_detectCli', function() { this.cliAvailable = false; this._reasonixPath = null; }); await adapter.refresh(); const health = adapter.getHealthInfo(); assert.equal(health.available, true); // keep valid data browsable! assert.equal(health.cli_available, false); assert.equal(health.session_source_available, true); assert.equal(health.session_count, 1); const session = adapter.getSession('session-1'); assert.ok(session); assert.equal(session.can_resume, false); // can_resume is false when CLI is absent }); }); describe('ReasonixAdapter exclusion rules', () => { let env; afterEach(() => { mock.restoreAll(); if (env) env.restoreEnv(); }); it('excludes archive and subagent folders', async () => { env = withTestFiles({ projects: { 'archive': [ { id: 'archived-session', meta: { id: 'archived-session' }, jsonl: '{"role":"user","content":"hi"}', }, ], 'subagents': [ { id: 'subagent-session', meta: { id: 'subagent-session' }, jsonl: '{"role":"user","content":"hi"}', }, ], 'real-project': [ { id: 'real-session', meta: { id: 'real-session' }, jsonl: '{"role":"user","content":"hi"}', }, ], }, }); const adapter = new ReasonixAdapter(); adapter.cliAvailable = true; await adapter.refresh(); assert.equal(adapter.getHealthInfo().session_count, 1); assert.ok(adapter.getSession('real-session')); assert.equal(adapter.getSession('archived-session'), null); assert.equal(adapter.getSession('subagent-session'), null); }); }); describe('ReasonixAdapter robustness and variants', () => { let env; afterEach(() => { mock.restoreAll(); if (env) env.restoreEnv(); }); it('requires both meta and main jsonl files', async () => { env = withTestFiles({ projects: { 'proj': [ { id: 'session-meta-only', meta: { id: 'session-meta-only' }, jsonlContent: undefined, // no jsonl file }, { id: 'session-valid', meta: { id: 'session-valid' }, jsonl: '{"role":"user","content":"hello"}', }, ], }, }); const adapter = new ReasonixAdapter(); adapter.cliAvailable = true; await adapter.refresh(); assert.equal(adapter.getHealthInfo().session_count, 1); assert.ok(adapter.getSession('session-valid')); assert.equal(adapter.getSession('session-meta-only'), null); }); it('tolerates malformed files without dropping valid peers', async () => { env = withTestFiles({ projects: { 'proj': [ { id: 'session-malformed-meta', metaContent: '{invalid json', jsonl: '{"role":"user","content":"hello"}', }, { id: 'session-malformed-jsonl', meta: { id: 'session-malformed-jsonl' }, jsonlContent: '{"role":"user",content:"missing quotes"}', }, { id: 'session-valid', meta: { id: 'session-valid' }, jsonl: '{"role":"user","content":"hello"}', }, ], }, }); const adapter = new ReasonixAdapter(); adapter.cliAvailable = true; await adapter.refresh(); assert.ok(adapter.getSession('session-valid')); // The invalid jsonl might still yield a session (with less or no messages) // but the malformed meta is definitely skipped. assert.equal(adapter.getSession('session-malformed-meta'), null); }); it('preserves independent recovery/conflict variants', async () => { env = withTestFiles({ projects: { 'proj': [ { id: 'session-123', meta: { id: 'session-123' }, jsonl: '{"role":"user","content":"hello"}', }, { id: 'session-123.conflict-1', meta: { id: 'session-123.conflict-1' }, jsonl: '{"role":"user","content":"hello conflict"}', }, ], }, }); const adapter = new ReasonixAdapter(); adapter.cliAvailable = true; await adapter.refresh(); assert.equal(adapter.getHealthInfo().session_count, 2); assert.ok(adapter.getSession('session-123')); assert.ok(adapter.getSession('session-123.conflict-1')); }); }); describe('ReasonixAdapter active signal and shell quoting', () => { let env; afterEach(() => { mock.restoreAll(); if (env) env.restoreEnv(); }); it('sets status based on lock file and builds quoted resume commands', async () => { env = withTestFiles({ projects: { 'proj': [ { id: 'session-active', meta: { id: 'session-active' }, jsonl: '{"role":"system","content":"Current workspace: \\"/path/with spaces/and \' quotes\\""}\n{"role":"user","content":"hi"}\n', hasLock: true, }, { id: 'session-inactive', meta: { id: 'session-inactive' }, jsonl: '{"role":"user","content":"hi"}\n', hasLock: false, }, ], }, }); const adapter = new ReasonixAdapter(); adapter.cliAvailable = true; await adapter.refresh(); const active = adapter.getSession('session-active'); const inactive = adapter.getSession('session-inactive'); assert.equal(active.status, 'active'); assert.equal(inactive.status, 'unknown'); // non-locked is unknown, not success or error // Shell quoting checks assert.equal(active.resume_command, "reasonix --dir '/path/with spaces/and '\\'' quotes' --resume"); assert.equal(inactive.resume_command, "reasonix --resume"); }); it('emits unified function_call and function_call_output types and respects 300 messages cap', async () => { let largeJsonl = '{"role":"user","content":"hi"}\n'; for (let i = 0; i < 350; i++) { largeJsonl += `{"role":"assistant","content":"doing step ${i}","tool_calls":[{"name":"my_tool","arguments":"{}"}]}\n`; largeJsonl += `{"role":"tool","content":"output ${i}"}\n`; } env = withTestFiles({ projects: { 'proj': [ { id: 'session-large', meta: { id: 'session-large' }, jsonl: largeJsonl, }, ], }, }); const adapter = new ReasonixAdapter(); adapter.cliAvailable = true; await adapter.refresh(); const messages = await adapter.getMessages('session-large'); assert.equal(messages.length, 300); // respects 300-entry cap // Check unified timeline types assert.equal(messages[0].type, 'user_message'); assert.equal(messages[1].type, 'assistant_message'); assert.equal(messages[2].type, 'function_call'); assert.equal(messages[3].type, 'function_call_output'); }); });