feat: add Reasonix session management
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 智能体观测中心
|
||||
|
||||
本地多引擎 AI 会话管理工具 — 浏览、搜索、复制恢复命令,支持 **Codex · Claude · Agy · OpenCode** 四引擎。
|
||||
本地多引擎 AI 会话管理工具 — 浏览、搜索、复制恢复命令,支持 **Codex · Claude · Agy · OpenCode · Reasonix** 五引擎。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -24,10 +24,10 @@ npm start
|
||||
|
||||
## 功能
|
||||
|
||||
- **四引擎会话列表** — 按引擎筛选(全部/Codex/Claude/Agy/OpenCode),搜索 ID、短 ID、路径、摘要
|
||||
- **五引擎会话列表** — 按引擎筛选(全部/Codex/Claude/Agy/OpenCode/Reasonix),搜索 ID、短 ID、路径、摘要
|
||||
- **会话详情** — 引擎专属元数据、消息时间线、工具调用统计
|
||||
- **短 ID 解析** — 输入部分 ID(如 `019f448b`)自动匹配
|
||||
- **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session`)
|
||||
- **一键恢复** — 复制引擎对应的恢复命令(`codex resume` / `claude --resume` / `agy --conversation` / `opencode --session` / `reasonix --resume`)
|
||||
- **工具排行** — 全引擎工具调用聚合排行,附技能/插件/缓存清单
|
||||
- **效能分析** — 会话趋势图、状态分布、异常率
|
||||
- **会话删除** — 支持 OpenCode 引擎的原始会话删除(通过 `opencode session delete` 执行)
|
||||
@@ -41,15 +41,17 @@ npm start
|
||||
| Claude | `~/.claude/projects/**/*.jsonl` | ~170 | `claude --resume <id>` |
|
||||
| Agy | `~/.gemini/antigravity-cli/conversation_summaries.db` | ~80 | `agy --conversation <id>` |
|
||||
| OpenCode | `~/.local/share/opencode/opencode.db` | ~520 | `opencode --session <id>` |
|
||||
| Reasonix | `~/.reasonix/projects/<project-slug>/sessions/` | - | `reasonix --dir <cwd> --resume` |
|
||||
|
||||
OpenCode 数据目录通过 `opencode debug paths` 自动检测,若 CLI 不可用则回退到 `~/.local/share/opencode`。
|
||||
Reasonix 数据目录默认是 `~/.reasonix`,可通过环境变量 `REASONIX_HOME` 自定义,其下包含 `projects/` 子目录。
|
||||
|
||||
## API
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /api/health` | 服务状态 + 四引擎健康 |
|
||||
| `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode` | 会话列表 |
|
||||
| `GET /api/health` | 服务状态 + 五引擎健康 |
|
||||
| `GET /api/sessions?engine=all\|codex\|claude\|agy\|opencode\|reasonix` | 会话列表 |
|
||||
| `GET /api/sessions/:engine/:id` | 会话详情 |
|
||||
| `GET /api/sessions/:engine/:id/tools` | 会话内工具统计 |
|
||||
| `DELETE /api/sessions/:engine/:id` | 删除会话(仅 OpenCode 引擎支持原始删除) |
|
||||
@@ -61,4 +63,5 @@ OpenCode 数据目录通过 `opencode debug paths` 自动检测,若 CLI 不可
|
||||
|
||||
- `CODEX_HOME` — Codex 根目录(默认 `~/.codex`)
|
||||
- `AGY_DATA_DIR` — Agy 数据目录(默认 `~/.gemini/antigravity-cli`)
|
||||
- `REASONIX_HOME` — Reasonix 根目录(默认 `~/.reasonix`)
|
||||
- `PORT` — 后端端口(默认 `3721`)
|
||||
|
||||
@@ -2,12 +2,14 @@ import { CodexAdapter } from './codex-adapter.js';
|
||||
import { ClaudeAdapter } from './claude-adapter.js';
|
||||
import { AgyAdapter } from './agy-adapter.js';
|
||||
import { OpenCodeAdapter } from './opencode-adapter.js';
|
||||
import { ReasonixAdapter } from './reasonix-adapter.js';
|
||||
|
||||
const ENGINES = {
|
||||
codex: new CodexAdapter(),
|
||||
claude: new ClaudeAdapter(),
|
||||
agy: new AgyAdapter(),
|
||||
opencode: new OpenCodeAdapter(),
|
||||
reasonix: new ReasonixAdapter(),
|
||||
};
|
||||
|
||||
export function getEngine(name) {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { BaseEngineAdapter } from './base.js';
|
||||
|
||||
const REASONIX_BIN_CANDIDATES = [
|
||||
join(homedir(), '.local', 'bin', 'reasonix'),
|
||||
'/usr/local/bin/reasonix',
|
||||
'/usr/bin/reasonix',
|
||||
];
|
||||
|
||||
const DEFAULT_DATA_DIR = join(homedir(), '.reasonix');
|
||||
|
||||
function shellQuote(str) {
|
||||
if (!str) return "''";
|
||||
return "'" + str.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
export class ReasonixAdapter extends BaseEngineAdapter {
|
||||
constructor() {
|
||||
super('reasonix', 'Reasonix');
|
||||
this._dataDir = null;
|
||||
this._reason = '';
|
||||
this.cliAvailable = false;
|
||||
this._messageCache = new Map();
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
this._detectCli();
|
||||
this._findDataDir();
|
||||
this.available = this._dataDir !== null && existsSync(join(this._dataDir, 'projects'));
|
||||
return this.getHealthInfo();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this._detectCli();
|
||||
this._findDataDir();
|
||||
this.lastRefresh = Date.now();
|
||||
this._messageCache.clear();
|
||||
this.error = null;
|
||||
|
||||
if (!this._dataDir || !existsSync(join(this._dataDir, 'projects'))) {
|
||||
this._sessions = [];
|
||||
this._sessionsMap.clear();
|
||||
this.available = false;
|
||||
return this.getHealthInfo();
|
||||
}
|
||||
|
||||
try {
|
||||
await this._scanSessions();
|
||||
this.available = true;
|
||||
} catch (err) {
|
||||
this.error = err.message;
|
||||
console.error('[ReasonixAdapter] Refresh error:', err.message);
|
||||
this._sessions = [];
|
||||
this._sessionsMap.clear();
|
||||
this.available = false;
|
||||
}
|
||||
|
||||
return this.getHealthInfo();
|
||||
}
|
||||
|
||||
_detectCli() {
|
||||
for (const path of REASONIX_BIN_CANDIDATES) {
|
||||
if (existsSync(path)) {
|
||||
this._reasonixPath = path;
|
||||
this.cliAvailable = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = execSync('which reasonix 2>/dev/null || echo ""', { encoding: 'utf-8', timeout: 3000 }).trim();
|
||||
if (result) {
|
||||
this._reasonixPath = result;
|
||||
this.cliAvailable = true;
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
this._reasonixPath = null;
|
||||
this.cliAvailable = false;
|
||||
this._reason = '未检测到 reasonix CLI';
|
||||
}
|
||||
|
||||
_findDataDir() {
|
||||
const dataDir = process.env.REASONIX_HOME || DEFAULT_DATA_DIR;
|
||||
if (existsSync(dataDir)) {
|
||||
this._dataDir = dataDir;
|
||||
this._reason = '';
|
||||
return;
|
||||
}
|
||||
this._dataDir = null;
|
||||
this._reason = `未找到 Reasonix 根目录: ${dataDir}`;
|
||||
}
|
||||
|
||||
async _scanSessions() {
|
||||
this._sessions = [];
|
||||
this._sessionsMap.clear();
|
||||
|
||||
const projectsPath = join(this._dataDir, 'projects');
|
||||
if (!existsSync(projectsPath)) {
|
||||
this._reason = '未找到 projects 目录';
|
||||
return;
|
||||
}
|
||||
|
||||
const projectDirs = readdirSync(projectsPath);
|
||||
for (const projDir of projectDirs) {
|
||||
// subagent/archive exclusion
|
||||
if (projDir === 'archive' || projDir === 'subagents') continue;
|
||||
|
||||
const projPath = join(projectsPath, projDir);
|
||||
if (!statSync(projPath).isDirectory()) continue;
|
||||
|
||||
const sessionsPath = join(projPath, 'sessions');
|
||||
if (!existsSync(sessionsPath)) continue;
|
||||
|
||||
const files = readdirSync(sessionsPath).filter(f => f.endsWith('.jsonl.meta'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
const metaPath = join(sessionsPath, file);
|
||||
if (!statSync(metaPath).isFile()) continue;
|
||||
|
||||
const id = file.replace(/\.jsonl\.meta$/, '');
|
||||
const jsonlPath = join(sessionsPath, `${id}.jsonl`);
|
||||
|
||||
// Require both .jsonl.meta and the matching main .jsonl before indexing a session
|
||||
if (!existsSync(jsonlPath)) continue;
|
||||
|
||||
const session = await this._parseSession(id, metaPath, jsonlPath, sessionsPath);
|
||||
if (session) {
|
||||
this._sessions.push(session.session);
|
||||
this._sessionsMap.set(id, session.session);
|
||||
if (session.messages?.length) {
|
||||
this._messageCache.set(id, session.messages);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[ReasonixAdapter] Skip malformed peer ${file}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._reason = `已加载 ${this._sessions.length} 个 Reasonix 会话`;
|
||||
}
|
||||
|
||||
async _parseSession(id, metaPath, jsonlPath, sessionsPath) {
|
||||
const metaContent = readFileSync(metaPath, 'utf8');
|
||||
const meta = JSON.parse(metaContent);
|
||||
|
||||
let firstUserMessage = '';
|
||||
let toolCallCount = 0;
|
||||
const toolCalls = {};
|
||||
let messageCount = 0;
|
||||
let userMessageCount = 0;
|
||||
let assistantMessageCount = 0;
|
||||
let cwd = '';
|
||||
const messages = [];
|
||||
|
||||
const jsonlContent = readFileSync(jsonlPath, 'utf8');
|
||||
const lines = jsonlContent.split('\n');
|
||||
|
||||
// Parse first line for workspace path if possible
|
||||
if (lines.length > 0 && lines[0].trim()) {
|
||||
try {
|
||||
const firstLine = JSON.parse(lines[0]);
|
||||
if (firstLine.role === 'system' && firstLine.content) {
|
||||
const match = firstLine.content.match(/Current workspace:\s*"([^"]+)"/);
|
||||
if (match) {
|
||||
cwd = match[1];
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
const role = entry.role || '';
|
||||
|
||||
if (role === 'user') {
|
||||
userMessageCount++;
|
||||
messageCount++;
|
||||
const text = entry.content || '';
|
||||
if (!firstUserMessage && text.trim()) {
|
||||
firstUserMessage = text;
|
||||
}
|
||||
messages.push({
|
||||
type: 'user_message',
|
||||
message: text.slice(0, 2000),
|
||||
time: meta.created_at ? new Date(meta.created_at).getTime() : 0,
|
||||
});
|
||||
} else if (role === 'assistant') {
|
||||
assistantMessageCount++;
|
||||
messageCount++;
|
||||
const text = entry.content || '';
|
||||
if (text.trim()) {
|
||||
messages.push({
|
||||
type: 'assistant_message',
|
||||
message: text.slice(0, 2000),
|
||||
time: meta.updated_at ? new Date(meta.updated_at).getTime() : 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(entry.tool_calls)) {
|
||||
for (const tc of entry.tool_calls) {
|
||||
toolCallCount++;
|
||||
const name = tc.name || 'unknown';
|
||||
toolCalls[name] = (toolCalls[name] || 0) + 1;
|
||||
messages.push({
|
||||
type: 'function_call',
|
||||
name,
|
||||
arguments: tc.arguments || '',
|
||||
time: meta.updated_at ? new Date(meta.updated_at).getTime() : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (role === 'tool') {
|
||||
const content = entry.content || '';
|
||||
messages.push({
|
||||
type: 'function_call_output',
|
||||
output_preview: content.slice(0, 300),
|
||||
time: meta.updated_at ? new Date(meta.updated_at).getTime() : 0,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const created = meta.created_at ? new Date(meta.created_at).getTime() : 0;
|
||||
const updated = meta.updated_at ? new Date(meta.updated_at).getTime() : created;
|
||||
|
||||
// Matching .jsonl.lock as the only active signal, set every other to unknown
|
||||
const lockPath = join(sessionsPath, `${id}.jsonl.lock`);
|
||||
const status = existsSync(lockPath) ? 'active' : 'unknown';
|
||||
|
||||
return {
|
||||
session: {
|
||||
key: `reasonix:${id}`,
|
||||
engine: 'reasonix',
|
||||
engine_label: 'Reasonix',
|
||||
id,
|
||||
summary: meta.preview || firstUserMessage || '(无消息)',
|
||||
cwd: cwd || '',
|
||||
model: meta.model || 'reasonix',
|
||||
cli_version: '',
|
||||
source: 'cli',
|
||||
created_at: created,
|
||||
updated_at: updated,
|
||||
duration_ms: created && updated ? updated - created : null,
|
||||
status,
|
||||
tool_call_count: toolCallCount,
|
||||
tool_calls: toolCalls,
|
||||
message_count: messageCount,
|
||||
resume_command: this.buildResumeCommand(id, cwd),
|
||||
can_resume: this.cliAvailable,
|
||||
can_delete: false,
|
||||
},
|
||||
messages: messages.slice(0, 300),
|
||||
};
|
||||
}
|
||||
|
||||
async getMessages(id) {
|
||||
if (this._messageCache.has(id)) {
|
||||
return this._messageCache.get(id);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
buildResumeCommand(id, cwdOpt) {
|
||||
const cwd = cwdOpt || this._sessionsMap.get(id)?.cwd;
|
||||
if (cwd) {
|
||||
return `reasonix --dir ${shellQuote(cwd)} --resume`;
|
||||
}
|
||||
return 'reasonix --resume';
|
||||
}
|
||||
|
||||
getHealthInfo() {
|
||||
return {
|
||||
available: this.available,
|
||||
cli_available: this.cliAvailable,
|
||||
session_source_available: this._dataDir !== null && existsSync(join(this._dataDir, 'projects')),
|
||||
session_count: this._sessions.length,
|
||||
last_refresh: this.lastRefresh,
|
||||
error: this.error,
|
||||
binary_path: this._reasonixPath,
|
||||
data_dir: this._dataDir,
|
||||
reason: this._reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ app.post('/api/refresh', async (req, res) => {
|
||||
});
|
||||
|
||||
// ─── Custom Name ──────────────────────────────────────────────────
|
||||
const VALID_ENGINES = ['codex', 'claude', 'agy', 'opencode'];
|
||||
const VALID_ENGINES = ['codex', 'claude', 'agy', 'opencode', 'reasonix'];
|
||||
|
||||
app.put('/api/sessions/:engine/:id/custom-name', (req, res) => {
|
||||
try {
|
||||
@@ -316,7 +316,7 @@ app.delete('/api/sessions/:engine/:id/custom-name', (req, res) => {
|
||||
app.post('/api/sessions/:engine/:id/generate-title', async (req, res) => {
|
||||
try {
|
||||
const { engine, id } = req.params;
|
||||
if (!['codex', 'claude', 'agy', 'opencode'].includes(engine)) {
|
||||
if (!['codex', 'claude', 'agy', 'opencode', 'reasonix'].includes(engine)) {
|
||||
return res.status(400).json({ error: `Invalid engine: ${engine}` });
|
||||
}
|
||||
const e = getEngine(engine);
|
||||
|
||||
@@ -175,6 +175,7 @@ async function fetchMessages(engine, sessionId, adapter) {
|
||||
if (engine === 'claude') return await fetchClaudeMessages(sessionId);
|
||||
if (engine === 'agy') return fetchAgyMessages(session);
|
||||
if (engine === 'opencode') return await fetchOpenCodeMessages(sessionId, adapter);
|
||||
if (engine === 'reasonix') return await fetchOpenCodeMessages(sessionId, adapter);
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
+12
-3
@@ -17,7 +17,7 @@
|
||||
<h1 class="logo-title">智能体观测中心</h1>
|
||||
<span class="logo-badge">混合引擎</span>
|
||||
</div>
|
||||
<p class="logo-sub">Codex · Claude · Agy · OpenCode 协同会话追踪平台</p>
|
||||
<p class="logo-sub">Codex · Claude · Agy · OpenCode · Reasonix 协同会话追踪平台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,6 +159,7 @@ const engineOptions = [
|
||||
{ key: 'claude', label: 'Claude', icon: '🧠', short: 'Claude' },
|
||||
{ key: 'agy', label: 'Agy', icon: '⚙', short: 'Agy' },
|
||||
{ key: 'opencode', label: 'OpenCode', icon: '🔓', short: 'OpenCode' },
|
||||
{ key: 'reasonix', label: 'Reasonix', icon: '🔍', short: 'Reasonix' },
|
||||
]
|
||||
|
||||
const tabs = [
|
||||
@@ -416,11 +417,19 @@ code, .mono { font-family: 'SF Mono', 'Fira Code', monospace; }
|
||||
.footer-addr { font-family: 'SF Mono', monospace; color: var(--accent-light); font-weight: 600; }
|
||||
|
||||
.main-content { flex: 1; overflow-y: auto; padding: 24px; min-width: 0; }
|
||||
.engine-switcher-mobile { display: none; gap: 4px; margin-bottom: 12px; }
|
||||
.engine-switcher-mobile .engine-btn { flex: 1; justify-content: center; padding: 8px 4px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer; }
|
||||
.engine-switcher-mobile { display: none; gap: 8px; margin-bottom: 12px; overflow-x: auto; padding: 4px; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.engine-switcher-mobile::-webkit-scrollbar { display: none; }
|
||||
.engine-switcher-mobile .engine-btn { flex-shrink: 0; justify-content: center; padding: 10px 16px; min-height: 44px; min-width: 80px; border-radius: 8px; border: 1px solid var(--border); background: transparent; color: var(--text-muted); font-size: 11px; font-weight: 600; cursor: pointer; }
|
||||
.engine-switcher-mobile .engine-btn.active { color: #fff; border: none; }
|
||||
.sidebar-overlay { display: none; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.logo-sub { display: none; }
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.header-stats { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-btn { display: block; }
|
||||
.engine-switcher { display: none; }
|
||||
|
||||
@@ -117,6 +117,7 @@ export const ENGINE_META = {
|
||||
claude: { color: '#a855f7', bg: 'rgba(168,85,247,0.1)', label: 'Claude', border: 'rgba(168,85,247,0.2)', icon: '🧠' },
|
||||
agy: { color: '#10b981', bg: 'rgba(16,185,129,0.1)', label: 'Agy', border: 'rgba(16,185,129,0.2)', icon: '⚙' },
|
||||
opencode: { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', label: 'OpenCode', border: 'rgba(245,158,11,0.2)', icon: '🔓' },
|
||||
reasonix: { color: '#ef4444', bg: 'rgba(239,68,68,0.1)', label: 'Reasonix', border: 'rgba(239,68,68,0.2)', icon: '🔍' },
|
||||
}
|
||||
|
||||
export function getEngineMeta(engine) {
|
||||
|
||||
@@ -85,7 +85,7 @@ const sessions = ref([])
|
||||
const totalSessions = ref(0)
|
||||
const errorCount = ref(0)
|
||||
const engineLabel = computed(() => {
|
||||
const labels = { all: '全部引擎', codex: 'Codex', claude: 'Claude', agy: 'Agy', opencode: 'OpenCode' }
|
||||
const labels = { all: '全部引擎', codex: 'Codex', claude: 'Claude', agy: 'Agy', opencode: 'OpenCode', reasonix: 'Reasonix' }
|
||||
return labels[selectedEngine.value] || selectedEngine.value
|
||||
})
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ const maxSessionCount = ref(0)
|
||||
|
||||
const engineLabel = computed(() => {
|
||||
if (selectedEngine.value === 'all') return '全部引擎'
|
||||
const labels = { codex: 'Codex', claude: 'Claude', agy: 'Agy' }
|
||||
const labels = { codex: 'Codex', claude: 'Claude', agy: 'Agy', opencode: 'OpenCode', reasonix: 'Reasonix' }
|
||||
return labels[selectedEngine.value] || selectedEngine.value
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user