mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* chore(agent): 增加对 Qoder CLI 的支持和识别 - 在 QUICKSTART 文档中添加 Qoder CLI 为可选本地 agent CLI - 更新代码中 agents.ts 注释包含 Qoder CLI 扫描支持 - 修改首次加载时检测的可用 CLI 列表,加入 Qoder CLI - 在多个语言版本的 README 中增加 Qoder CLI 支持及相关徽章统计 - 更新 agent 适配器与事件解析相关的代码注释和文档,包含 qoder-stream-json 解析器 - 调整 Windows 下 spawn 行为以支持 Qoder CLI 的 stdin 提供 prompt - 修复多语言文档对支持的 CLI 数量描述错误,确保数据保持同步 Change-Id: I388f2f61c60ce8faa7cef5d84eb407950f8bdbfb Co-developed-by: Qoder <noreply@qoder.com> * chore(agent): 增加对 Qoder CLI 的支持和识别 - 在 QUICKSTART 文档中添加 Qoder CLI 为可选本地 agent CLI - 更新代码中 agents.ts 注释包含 Qoder CLI 扫描支持 - 修改首次加载时检测的可用 CLI 列表,加入 Qoder CLI - 在多个语言版本的 README 中增加 Qoder CLI 支持及相关徽章统计 - 更新 agent 适配器与事件解析相关的代码注释和文档,包含 qoder-stream-json 解析器 - 调整 Windows 下 spawn 行为以支持 Qoder CLI 的 stdin 提供 prompt - 修复多语言文档对支持的 CLI 数量描述错误,确保数据保持同步 Change-Id: Id33f125b7c0b1a1c0b0274073da74d1578c324f7 Co-developed-by: Qoder <noreply@qoder.com> * feat(agent-icon): 添加新的Qoder徽标SVG图形组件 - 新增qoderGlyph函数,返回指定大小的SVG格式图形 - 图形包含多路径定义,颜色使用深灰和绿色填充 - 该组件可用于替代或补充现有AgentIcon图标功能 - 提升应用程序的品牌标识和视觉表现力 Change-Id: I4eca18166b5e33bc6229b40b2531d5a54607a560 Co-developed-by: Qoder <noreply@qoder.com> * Translate to English: --- **docs(readme): update to expand CLI agents to 16** - Increased the number of coding agent CLIs from 11 to 16 - New agents included: Devin for Terminal, Kiro CLI, Kilo, Mistral Vibe CLI, DeepSeek TUI **docs(readme): update to expand supported coding agents to 16** - Increased the number of supported code agent CLIs from 11 to 16 - Added support for new CLI tools: Devin for Terminal, Kiro CLI, Kilo, Mistral Vibe CLI, DeepSeek CLI - Added automatic CLI detection and switching while maintaining support for more agents - Added BYOK proxy TUI - Expanded compatibility and support coverage in the README’s multiple language versions - Reflected changes across all README translations (Arabic, German, French, Japanese, Korean) - Updated badges and descriptions to reflect CLI count and feature changes - Added event parsers and protocols for the new CLIs in the agent transport implementation - Updated the BYOK proxy and tool exploration features to be compatible with the expanded CLIs Change-Id: I89786b4a0b09bd279fb23265c2177076206fc5af Co-developed-by: Qoder <noreply@qoder.com> * feat(daemon): 支持 imagePaths 参数作为附件路径传递给 Qoder - 修改 buildArgs 函数,添加 --attachment 参数处理 imagePaths 中的绝对路径 - 过滤并忽略空字符串、非字符串及相对路径的 imagePaths 项 - 在单元测试中覆盖 imagePaths 参数支持及无效项过滤逻辑 - 在文档中补充 Qoder 运行时适配器对 --attachment 参数的说明 Change-Id: Ibfc3583ba86c6d258d524912559e97b77bf1dc87 Co-developed-by: Qoder <noreply@qoder.com> * docs(runtime): 说明Qoder适配器继承用户令牌的环境变量 - 添加文档说明检测代理仅为可用性探针,不进行身份验证 - 说明Qoder CLI账号状态独立,认证通过运行时错误路径反馈 - 详细描述子进程环境继承机制及静态环境变量与用户私密令牌区分 - 明确QODER_PERSONAL_ACCESS_TOKEN通过守护进程环境传递,不写入静态环境 - 解释Qoder验证由Qoder CLI负责,支持持久登录和自动化环境变量注入 test(agent): 添加QODER_PERSONAL_ACCESS_TOKEN环境变量继承测试 - 验证qoder适配器环境继承守护进程中的QODER_PERSONAL_ACCESS_TOKEN - 确认qoder适配器未在静态环境变量中定义用户令牌 - 保证用户私密令牌不会被写入静态适配器环境配置 Change-Id: Ie61869afbe497df1b16879b4e47b35123f758ed8 Co-developed-by: Qoder <noreply@qoder.com> * fix(daemon): 改进Qoder模式支持及错误处理机制 - 更新Qoder CLI参数,使用`--yolo`替代`--permission-mode bypass_permissions` - 将工作目录参数从`--cwd`改为`-w`以符合Qoder文档 - 在agent流事件处理中新增错误捕获并通过SSE错误事件发送 - 运行结束时若检测到agent流错误,则标记运行失败 - 测试中fix(daemon): 优化Qoder代理参数与错误处理 - 调整Qoder启动参数,改用`--yolo`和`-w`替代旧参数,避开argv长度限制 - 增强代理流事件处理,捕获并通过SSE错误通同步更新Qoder参数使用及相应断言 - 新增端到端测试,覆盖Qoder助手错误通过SSE错误通道反馈及运行状态失败处理 - 补充工具函数辅助测试事件流读取与运行状态轮询 Change-Id: I5d933745c3659e093b0d2d807f22726e7f83eb48 Co-developed-by: Qoder <noreply@qoder.com> * feat(qoder-stream): 识别并报告Qoder运行错误事件 - 新增messageFromResult函数以从结果对象提取错误信息 - 在处理result事件时根据is_error字段触发error事件 - error事件携带具体错误消息和原始数据 - 添加测试验证Qoder运行返回is_error且退出码为0时正确触发错误事件 - 更新qoder流解析测试以校验错误事件映射 - 在聊天路由测试中增加针对Qoder错误运行的端到端场景验证 Change-Id: Ie98ac518135dbec3181c52de5a49afdea993e279 Co-developed-by: Qoder <noreply@qoder.com>
253 lines
9.8 KiB
TypeScript
253 lines
9.8 KiB
TypeScript
/**
|
|
* Smoke tests for the Critique Theater spawn-path branch.
|
|
*
|
|
* These tests exercise the loadCritiqueConfigFromEnv gate and the
|
|
* runOrchestrator integration point without actually spawning a child process.
|
|
* The spawn wiring lives in server.ts (ts-nocheck), so we test the seam
|
|
* through the public module APIs: config loading and orchestrator execution
|
|
* with a synthetic stdout iterable.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { mkdtempSync } from 'node:fs';
|
|
import { rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import Database from 'better-sqlite3';
|
|
import { migrateCritique, getCritiqueRun } from '../src/critique/persistence.js';
|
|
import { loadCritiqueConfigFromEnv } from '../src/critique/config.js';
|
|
import { runOrchestrator, type CritiqueSseBus } from '../src/critique/orchestrator.js';
|
|
import type { CritiqueSseEvent } from '@open-design/contracts/critique';
|
|
import { defaultCritiqueConfig } from '@open-design/contracts/critique';
|
|
|
|
function freshDb(): Database.Database {
|
|
const db = new Database(':memory:');
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
db.exec(`
|
|
CREATE TABLE projects (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE conversations (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
|
|
);
|
|
INSERT INTO projects (id, name, created_at, updated_at) VALUES ('p1', 'p1', 0, 0);
|
|
`);
|
|
migrateCritique(db);
|
|
return db;
|
|
}
|
|
|
|
function makeBus(): { bus: CritiqueSseBus; events: CritiqueSseEvent[] } {
|
|
const events: CritiqueSseEvent[] = [];
|
|
const bus: CritiqueSseBus = { emit: (e) => { events.push(e); } };
|
|
return { bus, events };
|
|
}
|
|
|
|
let tmpDir: string;
|
|
let db: Database.Database;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'od-spawn-wiring-test-'));
|
|
db = freshDb();
|
|
});
|
|
afterEach(async () => {
|
|
db.close();
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config gate: OD_CRITIQUE_ENABLED=false (legacy path unchanged)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('spawn wiring - cfg.enabled=false (M0 default)', () => {
|
|
it('loadCritiqueConfigFromEnv with empty env returns enabled=false', () => {
|
|
const cfg = loadCritiqueConfigFromEnv({});
|
|
expect(cfg.enabled).toBe(false);
|
|
});
|
|
|
|
it('loadCritiqueConfigFromEnv with OD_CRITIQUE_ENABLED=false returns enabled=false', () => {
|
|
const cfg = loadCritiqueConfigFromEnv({ OD_CRITIQUE_ENABLED: 'false' });
|
|
expect(cfg.enabled).toBe(false);
|
|
});
|
|
|
|
it('when cfg.enabled=false, runOrchestrator is not invoked (legacy path)', async () => {
|
|
// Simulate what the spawn branch does: check cfg.enabled before calling orchestrator.
|
|
const cfg = loadCritiqueConfigFromEnv({});
|
|
expect(cfg.enabled).toBe(false);
|
|
// No orchestrator call means no row inserted.
|
|
expect(getCritiqueRun(db, 'legacy-run')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config gate: OD_CRITIQUE_ENABLED=true (orchestrator path)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('spawn wiring - cfg.enabled=true (orchestrator path)', () => {
|
|
it('with OD_CRITIQUE_ENABLED=true, runOrchestrator is invoked with the spawn stdout', async () => {
|
|
const cfg = loadCritiqueConfigFromEnv({ OD_CRITIQUE_ENABLED: '1' });
|
|
expect(cfg.enabled).toBe(true);
|
|
|
|
const { bus, events } = makeBus();
|
|
const artifactDir = join(tmpDir, 'run-enabled');
|
|
|
|
// Synthetic stdout that matches a minimal valid critique run.
|
|
async function* mockStdout(): AsyncIterable<string> {
|
|
yield '<CRITIQUE_RUN version="1" maxRounds="3" threshold="8.0" scale="10">\n';
|
|
yield ' <ROUND n="1">\n';
|
|
yield ' <PANELIST role="designer">\n';
|
|
yield ' <NOTES>v1</NOTES>\n';
|
|
yield ' <ARTIFACT mime="text/html"><![CDATA[<html></html>]]></ARTIFACT>\n';
|
|
yield ' </PANELIST>\n';
|
|
yield ' <PANELIST role="critic" score="9.0"><DIM name="h" score="9">ok</DIM></PANELIST>\n';
|
|
yield ' <PANELIST role="brand" score="9.0"><DIM name="v" score="9">ok</DIM></PANELIST>\n';
|
|
yield ' <PANELIST role="a11y" score="9.0"><DIM name="c" score="9">ok</DIM></PANELIST>\n';
|
|
yield ' <PANELIST role="copy" score="9.0"><DIM name="cl" score="9">ok</DIM></PANELIST>\n';
|
|
yield ' <ROUND_END n="1" composite="9.0" must_fix="0" decision="ship">\n';
|
|
yield ' <REASON>Ship on round 1.</REASON>\n';
|
|
yield ' </ROUND_END>\n';
|
|
yield ' </ROUND>\n';
|
|
yield ' <SHIP round="1" composite="9.0" status="shipped">\n';
|
|
yield ' <ARTIFACT mime="text/html"><![CDATA[<html></html>]]></ARTIFACT>\n';
|
|
yield ' <SUMMARY>Shipped.</SUMMARY>\n';
|
|
yield ' </SHIP>\n';
|
|
yield '</CRITIQUE_RUN>\n';
|
|
}
|
|
|
|
const result = await runOrchestrator({
|
|
runId: 'enabled-run',
|
|
projectId: 'p1',
|
|
conversationId: null,
|
|
artifactId: 'a1',
|
|
artifactDir,
|
|
adapter: 'claude',
|
|
cfg,
|
|
db,
|
|
bus,
|
|
stdout: mockStdout(),
|
|
});
|
|
|
|
// Orchestrator ran and returned a shipped result.
|
|
expect(result.status).toBe('shipped');
|
|
const row = getCritiqueRun(db, 'enabled-run');
|
|
expect(row?.status).toBe('shipped');
|
|
|
|
// SSE events were emitted on the bus.
|
|
const eventNames = events.map((e) => e.event);
|
|
expect(eventNames).toContain('critique.run_started');
|
|
expect(eventNames).toContain('critique.ship');
|
|
});
|
|
|
|
it('errors thrown by the orchestrator surface to the caller', async () => {
|
|
const cfg = loadCritiqueConfigFromEnv({ OD_CRITIQUE_ENABLED: '1' });
|
|
|
|
// Invalid cfg to force a RangeError before any side effect.
|
|
const badCfg = { ...cfg, perRoundTimeoutMs: -1 };
|
|
const { bus } = makeBus();
|
|
|
|
await expect(
|
|
runOrchestrator({
|
|
runId: 'error-run',
|
|
projectId: 'p1',
|
|
conversationId: null,
|
|
artifactId: 'a1',
|
|
artifactDir: join(tmpDir, 'run-error'),
|
|
adapter: 'claude',
|
|
cfg: badCfg,
|
|
db,
|
|
bus,
|
|
stdout: (async function* () { yield ''; })(),
|
|
}),
|
|
).rejects.toThrow(RangeError);
|
|
|
|
// No row inserted because error fires before insertCritiqueRun.
|
|
expect(getCritiqueRun(db, 'error-run')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stream format gating (Defect 1)
|
|
// ---------------------------------------------------------------------------
|
|
// The server gates the orchestrator path on streamFormat === 'plain'.
|
|
// We test the logic inline: simulate the server branch condition and verify
|
|
// that non-plain adapters skip the orchestrator entirely.
|
|
|
|
describe('spawn wiring - stream format gating (Defect 1)', () => {
|
|
const NON_PLAIN_FORMATS = [
|
|
'claude-stream-json',
|
|
'qoder-stream-json',
|
|
'copilot-stream-json',
|
|
'json-event-stream',
|
|
'acp-json-rpc',
|
|
] as const;
|
|
|
|
for (const fmt of NON_PLAIN_FORMATS) {
|
|
it(`format="${fmt}" skips the orchestrator (no run row inserted)`, async () => {
|
|
// Simulate the server branch: if streamFormat !== 'plain', skip orchestrator.
|
|
const cfg = loadCritiqueConfigFromEnv({ OD_CRITIQUE_ENABLED: '1' });
|
|
const adapterStreamFormat: string = fmt;
|
|
|
|
if (cfg.enabled && adapterStreamFormat !== 'plain') {
|
|
// Legacy path: orchestrator NOT called.
|
|
// Nothing should be inserted.
|
|
expect(getCritiqueRun(db, `skip-${fmt}`)).toBeNull();
|
|
return;
|
|
}
|
|
|
|
// If we reach here, the test scenario is wrong.
|
|
throw new Error(`Expected ${fmt} to skip orchestrator but did not`);
|
|
});
|
|
}
|
|
|
|
it('format="plain" routes through the orchestrator', async () => {
|
|
const cfg = loadCritiqueConfigFromEnv({ OD_CRITIQUE_ENABLED: '1' });
|
|
const adapterStreamFormat = 'plain';
|
|
|
|
// Simulate: only call orchestrator when format is plain.
|
|
if (!cfg.enabled || adapterStreamFormat !== 'plain') {
|
|
throw new Error('Expected plain format to be routed through orchestrator');
|
|
}
|
|
|
|
const { bus } = makeBus();
|
|
const artifactDir = join(tmpDir, 'run-plain-format');
|
|
|
|
async function* mockStdout(): AsyncIterable<string> {
|
|
yield '<CRITIQUE_RUN version="1" maxRounds="1" threshold="8.0" scale="10">\n';
|
|
yield ' <ROUND n="1">\n';
|
|
yield ' <PANELIST role="designer"><NOTES>v1</NOTES><ARTIFACT mime="text/html"><![CDATA[<p>v1</p>]]></ARTIFACT></PANELIST>\n';
|
|
yield ' <PANELIST role="critic" score="9.0"><DIM name="h" score="9">ok</DIM></PANELIST>\n';
|
|
yield ' <PANELIST role="brand" score="9.0"><DIM name="v" score="9">ok</DIM></PANELIST>\n';
|
|
yield ' <PANELIST role="a11y" score="9.0"><DIM name="c" score="9">ok</DIM></PANELIST>\n';
|
|
yield ' <PANELIST role="copy" score="9.0"><DIM name="cl" score="9">ok</DIM></PANELIST>\n';
|
|
yield ' <ROUND_END n="1" composite="9.0" must_fix="0" decision="ship"><REASON>ok</REASON></ROUND_END>\n';
|
|
yield ' </ROUND>\n';
|
|
yield ' <SHIP round="1" composite="9.0" status="shipped">\n';
|
|
yield ' <ARTIFACT mime="text/html"><![CDATA[<p>final</p>]]></ARTIFACT>\n';
|
|
yield ' <SUMMARY>done</SUMMARY>\n';
|
|
yield ' </SHIP>\n';
|
|
yield '</CRITIQUE_RUN>\n';
|
|
}
|
|
|
|
const result = await runOrchestrator({
|
|
runId: 'plain-format-run',
|
|
projectId: 'p1',
|
|
conversationId: null,
|
|
artifactId: 'a1',
|
|
artifactDir,
|
|
adapter: 'plain-adapter',
|
|
cfg,
|
|
db,
|
|
bus,
|
|
stdout: mockStdout(),
|
|
});
|
|
|
|
expect(result.status).toBe('shipped');
|
|
expect(getCritiqueRun(db, 'plain-format-run')?.status).toBe('shipped');
|
|
});
|
|
});
|