327 lines
9.4 KiB
JavaScript
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');
|
|
});
|
|
});
|