Files

327 lines
9.4 KiB
JavaScript

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