scheduler-gateway/test/plan-confirmation.test.mjs
Liuxinyu176 bbb364cd69 feat: DSH 调度网关(互联网关)v6.0.0 — 四模型合并版 R1+R2+R3
- 零第三方依赖,Node >=20 原生 ESM
- 团队式编排引擎:拆解/路由/执行/审查/合并全真实 LLM
- 阶段心跳、单一权威清单守卫、all-keys-failed 如实上报
- H4 会话视图/amend/watchdog 有界重试/产物区 artifacts.json
- H5 零依赖三栏控制台
2026-10-09 23:20:27 +08:00

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