- 零第三方依赖,Node >=20 原生 ESM - 团队式编排引擎:拆解/路由/执行/审查/合并全真实 LLM - 阶段心跳、单一权威清单守卫、all-keys-failed 如实上报 - H4 会话视图/amend/watchdog 有界重试/产物区 artifacts.json - H5 零依赖三栏控制台
158 lines
9.9 KiB
JavaScript
158 lines
9.9 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { Script, runInNewContext } from 'node:vm';
|
|
import { versionPlan, recordPlanDecision, waitForPlanDecision, readPlanJson } from '../src/team/plan-confirmation.js';
|
|
import { TeamOrchestrator } from '../src/team/orchestrate.js';
|
|
import { TeamPanelServer } from '../src/team/panel-server.js';
|
|
|
|
const planData = () => ({ subtasks: [{ id: 'st-1', title: 'Project report', goal: 'Summarize progress', acceptance: ['Include risks and next steps'], expectedFile: 'report.md', expectedArtifact: 'Project report', capabilities: ['writing'] }] });
|
|
const save = (dir, name, data) => writeFileSync(join(dir, name), JSON.stringify(data));
|
|
function temp(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'gateway-plan-test-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
return root;
|
|
}
|
|
async function until(fn) {
|
|
for (let i = 0; i < 200; i++) { const value = fn(); if (value) return value; await delay(10); }
|
|
throw new Error('Timed out waiting for fixture');
|
|
}
|
|
function waiting(dir, version = 1) {
|
|
const plan = versionPlan(planData(), 'A simple goal', version);
|
|
save(dir, 'plan.json', plan); save(dir, 'live-state.json', { stage: 'await-confirm' }); return plan;
|
|
}
|
|
|
|
test('actual R7 page compiles and has one implementation per function', () => {
|
|
const html = readFileSync(new URL('../AI团队协作台-统一面板-R7.html', import.meta.url), 'utf8');
|
|
const source = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)][0][1];
|
|
new Script(source);
|
|
const names = [...source.matchAll(/^(?:async )?function (\w+)\(/gm)].map(m => m[1]);
|
|
assert.equal(names.length, new Set(names).size);
|
|
assert.match(html, /id="conversationForm"/);
|
|
assert.match(html, /id="planReview"/);
|
|
});
|
|
|
|
|
|
test('failed AI revision keeps a valid plan and still requires fresh consent', async t => {
|
|
const root = temp(t), runId = 'test-revision-failure', dir = join(root, 'evidence/team-orch', runId);
|
|
const orch = new TeamOrchestrator({ root, mode: 'OFFLINE', logger: {} }); let attempts = 0;
|
|
orch.decompose = async () => { if (++attempts > 1) throw new Error('AI unavailable'); return planData(); };
|
|
const job = orch.run('A simple goal', { runId, confirmPollMs: 5, confirmTimeoutMs: 2000 });
|
|
await until(() => readPlanJson(join(dir, 'live-state.json'))?.stage === 'await-confirm');
|
|
const p1 = readPlanJson(join(dir, 'plan.json'));
|
|
recordPlanDecision(dir, { revision: p1.revision, action: 'revise', message: 'Change format' });
|
|
const p2 = await until(() => { const p = readPlanJson(join(dir, 'plan.json')); return p?.version === 2 && p; });
|
|
assert.equal(p2.subtasks[0].expectedFile, p1.subtasks[0].expectedFile);
|
|
assert.notEqual(p2.revision, p1.revision);
|
|
assert.equal(existsSync(join(dir, 'routes.json')), false);
|
|
recordPlanDecision(dir, { revision: p2.revision, action: 'cancel' });
|
|
await job;
|
|
assert.ok(readPlanJson(join(dir, 'conversation.json')).some(m => m.kind === 'plan-error'));
|
|
});
|
|
|
|
test('UI retains a rejected idea and does not show a false created task', async () => {
|
|
const html = readFileSync(new URL('../AI团队协作台-统一面板-R7.html', import.meta.url), 'utf8');
|
|
const source = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)][0][1];
|
|
const nodes = new Map();
|
|
const element = id => {
|
|
if (!nodes.has(id)) nodes.set(id, { value: '', textContent: '', innerHTML: '', hidden: false, disabled: false, setAttribute() {}, classList: { add() {}, remove() {} } });
|
|
return nodes.get(id);
|
|
};
|
|
const prefix = source.slice(0, source.indexOf('const UI_ACTIONS='));
|
|
const context = {
|
|
window: {}, localStorage: { getItem: () => null, setItem() {} },
|
|
document: { getElementById: element, querySelectorAll: () => [] },
|
|
fetch: async () => ({ ok: false, status: 503, json: async () => ({ error: 'AI unavailable' }) }),
|
|
setTimeout, clearTimeout, apiUnderTest: null,
|
|
};
|
|
runInNewContext(prefix + "\napiUnderTest={submitIdea,getCurrentRun:()=>currentRun};})();", context);
|
|
element('replyInput').value = 'My original idea';
|
|
await context.apiUnderTest.submitIdea('My original idea');
|
|
assert.equal(element('replyInput').value, 'My original idea');
|
|
assert.equal(element('conversationError').textContent, 'AI unavailable');
|
|
assert.equal(context.apiUnderTest.getCurrentRun(), null);
|
|
});
|
|
|
|
|
|
test('confirmation is revision-bound, idempotent, and cannot race a revision', t => {
|
|
const dir = temp(t), plan = waiting(dir);
|
|
assert.equal(recordPlanDecision(dir, { revision: 'a'.repeat(64), action: 'confirm' }).status, 409);
|
|
assert.equal(recordPlanDecision(dir, { revision: plan.revision, action: 'revise', message: 'Only one page' }).status, 200);
|
|
assert.equal(recordPlanDecision(dir, { revision: plan.revision, action: 'confirm' }).status, 409);
|
|
assert.equal(recordPlanDecision(dir, { revision: plan.revision, action: 'revise', message: 'Only one page' }).body.duplicate, true);
|
|
assert.equal(existsSync(join(dir, 'plan-confirmed.json')), false);
|
|
});
|
|
|
|
test('missing plans and execution-stage plans cannot be pre-approved', t => {
|
|
const dir = temp(t), revision = 'a'.repeat(64);
|
|
assert.equal(recordPlanDecision(dir, { revision, action: 'confirm' }).status, 404);
|
|
const plan = waiting(dir); save(dir, 'live-state.json', { stage: 'execute' });
|
|
assert.equal(recordPlanDecision(dir, { revision: plan.revision, action: 'confirm' }).status, 409);
|
|
assert.equal(recordPlanDecision(dir, { revision: '../escape', action: 'confirm' }).status, 400);
|
|
});
|
|
|
|
test('legacy marker and elapsed timeout never authorize execution', async t => {
|
|
const dir = temp(t), plan = waiting(dir);
|
|
save(dir, 'plan-confirmed.json', { by: 'legacy' });
|
|
await assert.rejects(waitForPlanDecision(dir, plan.revision, { pollMs: 5, timeoutMs: 20 }), /未执行/);
|
|
});
|
|
|
|
test('wait resolves only after explicit confirmation', async t => {
|
|
const dir = temp(t), plan = waiting(dir); let resolved = false;
|
|
const promise = waitForPlanDecision(dir, plan.revision, { pollMs: 5, timeoutMs: 500 }).then(r => { resolved = true; return r; });
|
|
await delay(25); assert.equal(resolved, false);
|
|
assert.equal(recordPlanDecision(dir, { revision: plan.revision, action: 'confirm' }).status, 200);
|
|
assert.equal((await promise).action, 'confirm');
|
|
});
|
|
|
|
test('real orchestrator revises AI-owned criteria and cancels without running tools', async t => {
|
|
const root = temp(t), runId = 'test-revision', dir = join(root, 'evidence/team-orch', runId);
|
|
const orch = new TeamOrchestrator({ root, mode: 'OFFLINE', logger: {} });
|
|
const inputs = [];
|
|
orch.decompose = async goal => { inputs.push(goal); const p = planData(); if (inputs.length > 1) { p.subtasks[0].expectedFile = 'summary.csv'; p.subtasks[0].acceptance = ['One row per milestone']; } return p; };
|
|
const job = orch.run('Organize my project', { runId, confirmPollMs: 5, confirmTimeoutMs: 2000 });
|
|
await until(() => readPlanJson(join(dir, 'live-state.json'))?.stage === 'await-confirm');
|
|
const p1 = readPlanJson(join(dir, 'plan.json'));
|
|
assert.equal(existsSync(join(dir, 'routes.json')), false);
|
|
recordPlanDecision(dir, { revision: p1.revision, action: 'revise', message: 'Make it a CSV' });
|
|
const p2 = await until(() => { const p = readPlanJson(join(dir, 'plan.json')); return p?.version === 2 && p; });
|
|
assert.notEqual(p2.revision, p1.revision); assert.equal(p2.subtasks[0].expectedFile, 'summary.csv');
|
|
assert.match(inputs[1], /Make it a CSV/);
|
|
assert.equal(recordPlanDecision(dir, { revision: p1.revision, action: 'confirm' }).status, 409);
|
|
recordPlanDecision(dir, { revision: p2.revision, action: 'cancel' });
|
|
assert.equal((await job).verdict, 'cancelled');
|
|
assert.equal(existsSync(join(dir, 'routes.json')), false);
|
|
assert.equal(existsSync(join(dir, 'plan-confirmed.json')), false);
|
|
});
|
|
|
|
test('explicit confirmation reaches routing, never before consent', async t => {
|
|
const root = temp(t), runId = 'test-execute', dir = join(root, 'evidence/team-orch', runId);
|
|
const old = process.env.GW_TEAM_STUB_CLI;
|
|
process.env.GW_TEAM_STUB_CLI = resolve('test/fixtures/fake-coding-agent.mjs');
|
|
t.after(() => { if (old === undefined) delete process.env.GW_TEAM_STUB_CLI; else process.env.GW_TEAM_STUB_CLI = old; });
|
|
const orch = new TeamOrchestrator({ root, mode: 'OFFLINE', logger: {} }); let routed = 0;
|
|
orch.decompose = async () => planData();
|
|
orch.route = async () => { routed++; throw new Error('TEST_STOP_BEFORE_EXECUTION'); };
|
|
const job = assert.rejects(orch.run('A test goal', { runId, confirmPollMs: 5, confirmTimeoutMs: 2000 }), /TEST_STOP_BEFORE_EXECUTION/);
|
|
await until(() => readPlanJson(join(dir, 'live-state.json'))?.stage === 'await-confirm');
|
|
await delay(25); assert.equal(routed, 0);
|
|
const p = readPlanJson(join(dir, 'plan.json')); recordPlanDecision(dir, { revision: p.revision, action: 'confirm' });
|
|
await job; assert.equal(routed, 1);
|
|
assert.equal(readPlanJson(join(dir, 'plan-confirmed.json')).revision, p.revision);
|
|
});
|
|
|
|
test('HTTP API validates plan versions and rejects path traversal', async t => {
|
|
const root = temp(t), dir = join(root, 'evidence/team-orch/test-api'); mkdirSync(dir, { recursive: true });
|
|
const plan = waiting(dir);
|
|
const server = new TeamPanelServer({ root, port: 0 }); const { port } = await server.listen();
|
|
t.after(() => new Promise(resolve => server.server.close(resolve)));
|
|
const post = (path, body) => fetch('http://127.0.0.1:' + port + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
assert.equal((await post('/api/team/confirm', { runId: '../escape', revision: plan.revision })).status, 400);
|
|
assert.equal((await post('/api/team/confirm', { runId: 'test-api' })).status, 400);
|
|
assert.equal((await post('/api/team/confirm', { runId: 'test-api', revision: plan.revision })).status, 200);
|
|
assert.equal((await post('/api/team/submit', { goal: '' })).status, 400);
|
|
});
|