@ludi-uni/ludi-agent-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +55 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/adapters/codex/README.md +24 -0
- package/adapters/codex/skill-metadata/visual-verification/agents/openai.yaml +7 -0
- package/adapters/pi/README.md +88 -0
- package/adapters/pi/browser/agent-browser.mjs +193 -0
- package/adapters/pi/lib/invoke.mjs +55 -0
- package/adapters/pi/lib/list-models.mjs +29 -0
- package/adapters/pi/lib/settings-proposal.mjs +34 -0
- package/adapters/pi/lib/subagent.mjs +175 -0
- package/adapters/pi/loop-guard/index.js +51 -0
- package/adapters/pi/maintenance-policy.json +36 -0
- package/adapters/pi/mcp.template.json +4 -0
- package/adapters/pi/model-catalog.json +97 -0
- package/adapters/pi/models.json +13 -0
- package/adapters/pi/models.local.example.json +14 -0
- package/adapters/pi/orchestrator-ext/command.mjs +14 -0
- package/adapters/pi/orchestrator-ext/index.js +150 -0
- package/adapters/pi/settings.template.json +7 -0
- package/adapters/pi/shell-gate/index.js +70 -0
- package/adapters/pi/sync-pi.ps1 +137 -0
- package/agents/README.md +26 -0
- package/agents/browser.md +64 -0
- package/agents/coder.md +31 -0
- package/agents/orchestrator.md +37 -0
- package/agents/reviewer.md +32 -0
- package/agents/scout.md +35 -0
- package/agents/tester.md +28 -0
- package/agents/visual.md +28 -0
- package/context-pack/SPEC.md +101 -0
- package/context-pack/context-pack.schema.json +79 -0
- package/context-pack/examples/example-fix.md +44 -0
- package/docs/architecture.md +55 -0
- package/docs/migration-from-codex-setting.md +44 -0
- package/docs/model-maintenance.md +401 -0
- package/docs/orchestrator.md +155 -0
- package/docs/phase2-report.md +39 -0
- package/docs/roadmap.md +27 -0
- package/docs/third-party.md +15 -0
- package/lib/agents.mjs +79 -0
- package/lib/context-pack.mjs +215 -0
- package/lib/job.mjs +312 -0
- package/lib/language-policy.mjs +27 -0
- package/lib/maintenance-exec.mjs +377 -0
- package/lib/maintenance-runner.mjs +266 -0
- package/lib/maintenance.mjs +422 -0
- package/lib/normalize.mjs +101 -0
- package/lib/observe/differ.mjs +185 -0
- package/lib/observe/observation.mjs +147 -0
- package/lib/observe/observers.mjs +134 -0
- package/lib/observe/sources.mjs +154 -0
- package/lib/orchestrator/activity.mjs +249 -0
- package/lib/orchestrator/api.mjs +151 -0
- package/lib/orchestrator/contract.mjs +68 -0
- package/lib/orchestrator/escalation.mjs +84 -0
- package/lib/orchestrator/evaluator.mjs +92 -0
- package/lib/orchestrator/failures.mjs +88 -0
- package/lib/orchestrator/health.mjs +53 -0
- package/lib/orchestrator/orchestrator.mjs +483 -0
- package/lib/orchestrator/permissions.mjs +64 -0
- package/lib/orchestrator/planner.mjs +194 -0
- package/lib/orchestrator/policy.mjs +134 -0
- package/lib/orchestrator/router.mjs +45 -0
- package/lib/orchestrator/runner.mjs +278 -0
- package/lib/orchestrator/shell-policy.mjs +52 -0
- package/lib/orchestrator/store.mjs +581 -0
- package/lib/orchestrator/task-store.mjs +79 -0
- package/lib/orchestrator/turn-budget.mjs +63 -0
- package/lib/orchestrator/worktree.mjs +72 -0
- package/lib/pipeline.mjs +279 -0
- package/lib/registry.mjs +63 -0
- package/lib/resolve.mjs +35 -0
- package/lib/routing.mjs +137 -0
- package/lib/telemetry.mjs +222 -0
- package/mcp/README.md +11 -0
- package/mcp/servers.json +13 -0
- package/orchestration/decision-policy.json +66 -0
- package/package.json +56 -0
- package/routing/README.md +24 -0
- package/routing/routing.json +81 -0
- package/routing/routing.schema.json +66 -0
- package/rules/README.md +10 -0
- package/rules/common.md +52 -0
- package/rules/loop-prevention.md +15 -0
- package/rules/repo-local.md +6 -0
- package/scripts/check-environment.ps1 +22 -0
- package/scripts/context-pack.mjs +17 -0
- package/scripts/e2e-investigate-repro.mjs +66 -0
- package/scripts/model-maintenance-job.mjs +59 -0
- package/scripts/observe-models.mjs +97 -0
- package/scripts/orchestrate.mjs +137 -0
- package/scripts/reevaluate-models.mjs +95 -0
- package/scripts/report-model-maintenance.mjs +70 -0
- package/scripts/resolve-capabilities.mjs +39 -0
- package/scripts/run-pipeline.mjs +56 -0
- package/scripts/sync-agents-md.ps1 +10 -0
- package/scripts/validate.mjs +71 -0
- package/skills/README.md +14 -0
- package/skills/pi-workflow/SKILL.md +26 -0
- package/skills/pi-workflow/references/code-investigation-and-fix.md +16 -0
- package/skills/pi-workflow/references/research.md +14 -0
- package/skills/pi-workflow/references/review.md +11 -0
- package/skills/pi-workflow/references/visual-work.md +14 -0
- package/skills/project-management/SKILL.md +106 -0
- package/skills/project-management/references/operations.md +52 -0
- package/skills/visual-verification/SKILL.md +88 -0
- package/skills/visual-verification/scripts/analyze-speech.ps1 +346 -0
- package/skills/visual-verification/scripts/backends/whisperx_backend.py +234 -0
- package/skills/visual-verification/scripts/common.ps1 +387 -0
- package/skills/visual-verification/scripts/contact-sheet.ps1 +121 -0
- package/skills/visual-verification/scripts/desktop-discover.ps1 +45 -0
- package/skills/visual-verification/scripts/desktop-inspect.ps1 +67 -0
- package/skills/visual-verification/scripts/desktop-record.ps1 +97 -0
- package/skills/visual-verification/scripts/desktop-screenshot.ps1 +65 -0
- package/skills/visual-verification/scripts/evaluate-sync.ps1 +249 -0
- package/skills/visual-verification/scripts/extract-frames.ps1 +79 -0
- package/skills/visual-verification/scripts/inspect-media.ps1 +138 -0
- package/skills/visual-verification/scripts/record-av.ps1 +102 -0
- package/skills/visual-verification/scripts/record.ps1 +72 -0
- package/skills/visual-verification/scripts/screenshot.ps1 +44 -0
- package/skills/visual-verification/scripts/waveform.ps1 +450 -0
- package/skills/visual-verification/scripts/winapp-common.ps1 +465 -0
- package/tests/activity.test.mjs +252 -0
- package/tests/attempt-budget.test.mjs +102 -0
- package/tests/browser.test.mjs +121 -0
- package/tests/context-pack.test.mjs +98 -0
- package/tests/dirty-gate.test.mjs +211 -0
- package/tests/e2e-browser.mjs +66 -0
- package/tests/e2e-real-orchestrator-resume.mjs +101 -0
- package/tests/e2e-real-orchestrator.mjs +41 -0
- package/tests/e2e-real-pi.mjs +27 -0
- package/tests/e2e-real-tool-orchestrator.mjs +66 -0
- package/tests/fixtures/browser-page/index.html +20 -0
- package/tests/fixtures/maintenance/availability.txt +5 -0
- package/tests/fixtures/maintenance/catalog.json +74 -0
- package/tests/fixtures/maintenance/events.json +13 -0
- package/tests/fixtures/math-repo/README.md +3 -0
- package/tests/fixtures/math-repo/package.json +7 -0
- package/tests/fixtures/math-repo/src/math.js +11 -0
- package/tests/fixtures/math-repo/test/math.test.js +7 -0
- package/tests/fixtures/observe/announcements.json +8 -0
- package/tests/fixtures/orch-concurrent-child.mjs +44 -0
- package/tests/fixtures/orch-persist-child.mjs +61 -0
- package/tests/job.test.mjs +230 -0
- package/tests/kit.test.mjs +79 -0
- package/tests/language-policy.test.mjs +93 -0
- package/tests/loop-guard.test.mjs +60 -0
- package/tests/maintenance-exec.test.mjs +218 -0
- package/tests/maintenance-runner.test.mjs +222 -0
- package/tests/maintenance.test.mjs +195 -0
- package/tests/observe.test.mjs +283 -0
- package/tests/observer-registry.test.mjs +157 -0
- package/tests/orchestrator-cleanup.test.mjs +358 -0
- package/tests/orchestrator-command.test.mjs +14 -0
- package/tests/orchestrator-persist.test.mjs +375 -0
- package/tests/orchestrator-tools.test.mjs +215 -0
- package/tests/orchestrator.test.mjs +396 -0
- package/tests/package.test.mjs +37 -0
- package/tests/pipeline.test.mjs +239 -0
- package/tests/planner-classification.test.mjs +81 -0
- package/tests/planner-split.test.mjs +67 -0
- package/tests/qoder-observer.test.mjs +266 -0
- package/tests/reassign-progression.test.mjs +104 -0
- package/tests/retry-escalation.test.mjs +120 -0
- package/tests/routing.test.mjs +110 -0
- package/tests/sqlite-concurrency.test.mjs +178 -0
- package/tests/task-global-e2e.test.mjs +63 -0
- package/tests/task-global-failed.test.mjs +134 -0
- package/tests/telemetry.test.mjs +173 -0
- package/tests/test-sync-pi.ps1 +56 -0
- package/tests/turn-budget.test.mjs +106 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Dirty-worktree gate state machine: user answers are honoured, agent-owned changes
|
|
2
|
+
// are not "dirty", non-git workspaces are never gated, read-only work continues.
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, resolve, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
11
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
12
|
+
import { DEFAULT_POLICY, mergePolicy } from '../lib/orchestrator/policy.mjs';
|
|
13
|
+
import { createAgentRunner, evaluateDirtyGate, DIRTY_GATE_TYPE } from '../lib/orchestrator/runner.mjs';
|
|
14
|
+
import { captureWorktree } from '../lib/orchestrator/worktree.mjs';
|
|
15
|
+
import { orchestrate } from '../lib/orchestrator/orchestrator.mjs';
|
|
16
|
+
import { openStore } from '../lib/orchestrator/store.mjs';
|
|
17
|
+
|
|
18
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
20
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
21
|
+
const REG = { version: 1, backends: {
|
|
22
|
+
local: { provider: 'pl', model: 'ml', thinking: 'off' },
|
|
23
|
+
cheap: { provider: 'pc', model: 'mc', thinking: 'low' },
|
|
24
|
+
sol: { provider: 'ps', model: 'ms', thinking: 'high' },
|
|
25
|
+
astra: { provider: 'pa', model: 'ma', thinking: 'medium', vision: true },
|
|
26
|
+
codex: { provider: 'px', model: 'mx', thinking: 'high' },
|
|
27
|
+
} };
|
|
28
|
+
const policyWith = over => mergePolicy(DEFAULT_POLICY, over ?? {});
|
|
29
|
+
const json = obj => `note\n\n\`\`\`json\n${JSON.stringify(obj)}\n\`\`\``;
|
|
30
|
+
const okResult = extra => json({ status: 'completed', summary: 'done', acceptance: [{ id: 'A1', met: true, evidence: 'ran' }, { id: 'A2', met: true, evidence: 'ran' }], ...extra });
|
|
31
|
+
const spec = (id, agent, dependencies = [], extra = {}) => ({ id, title: `task ${id}`, goal: `goal ${id}`, agent, dependencies, acceptance: ['done'], ...extra });
|
|
32
|
+
const dbPath = () => join(mkdtempSync(join(tmpdir(), 'ludi-gate-')), 'state.db');
|
|
33
|
+
|
|
34
|
+
function gitRepo({ dirty = [] } = {}) {
|
|
35
|
+
const root = mkdtempSync(join(tmpdir(), 'ludi-gate-repo-'));
|
|
36
|
+
assert.equal(spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' }).status, 0, 'git init');
|
|
37
|
+
writeFileSync(join(root, 'base.txt'), 'base\n');
|
|
38
|
+
spawnSync('git', ['add', '.'], { cwd: root });
|
|
39
|
+
spawnSync('git', ['-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-q', '-m', 'base'], { cwd: root });
|
|
40
|
+
for (const f of dirty) writeFileSync(join(root, f), 'user edit\n');
|
|
41
|
+
return root;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Subagent stub: writes `writes` files into cwd (simulating agent edits) then replies. */
|
|
45
|
+
function subagentStub({ writes = [], reply = okResult() } = {}) {
|
|
46
|
+
const calls = [];
|
|
47
|
+
const runSubagent = async req => {
|
|
48
|
+
calls.push({ taskId: req.taskId, modelId: req.modelId });
|
|
49
|
+
for (const f of writes) writeFileSync(join(req.cwd, f), `agent ${calls.length}\n`);
|
|
50
|
+
const r = typeof reply === 'function' ? reply(req, calls.length) : reply;
|
|
51
|
+
return typeof r === 'string' ? { ok: true, text: r, child: { childSessionId: `c${calls.length}`, toolCalls: 3 } } : r;
|
|
52
|
+
};
|
|
53
|
+
return { calls, runSubagent };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const runOrch = ({ repoRoot, runner, plan, session = null, policy, ...o }) => orchestrate({
|
|
57
|
+
request: 'implement it', plan, agents, routing, registry: REG, policy: policyWith(policy), runner, repoRoot, session, ...o,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ---------- unit: evaluateDirtyGate ----------
|
|
61
|
+
test('unit: gate classifies clean / owned-only / unrelated / non-git / answered', () => {
|
|
62
|
+
const git = paths => ({ source: 'git', entries: Object.fromEntries(paths.map(p => [p, { code: ' M', hash: 'x' }])) });
|
|
63
|
+
assert.equal(evaluateDirtyGate({ before: git([]) }).gate, false);
|
|
64
|
+
assert.equal(evaluateDirtyGate({ before: git(['a.txt']), ownedPaths: ['a.txt'] }).gate, false);
|
|
65
|
+
const ask = evaluateDirtyGate({ before: git(['a.txt', 'b.txt']), ownedPaths: ['a.txt'] });
|
|
66
|
+
assert.equal(ask.action, 'ask');
|
|
67
|
+
assert.deepEqual(ask.paths, ['b.txt']);
|
|
68
|
+
assert.match(ask.key, new RegExp(`^${DIRTY_GATE_TYPE}:`));
|
|
69
|
+
assert.equal(ask.decision.decisionType, DIRTY_GATE_TYPE);
|
|
70
|
+
// Same condition, answered by id/type (not by parsing the question).
|
|
71
|
+
assert.equal(evaluateDirtyGate({ before: git(['a.txt', 'b.txt']), ownedPaths: ['a.txt'], decisions: [{ key: ask.key, optionId: 'proceed' }] }).action, 'proceed');
|
|
72
|
+
assert.equal(evaluateDirtyGate({ before: git(['a.txt', 'b.txt']), ownedPaths: ['a.txt'], decisions: [{ key: ask.key, optionId: 'abort' }] }).action, 'abort');
|
|
73
|
+
// A different dirty set is a different question.
|
|
74
|
+
assert.equal(evaluateDirtyGate({ before: git(['c.txt']), decisions: [{ key: ask.key, optionId: 'proceed' }] }).action, 'ask');
|
|
75
|
+
// Non-git inventory is never dirty.
|
|
76
|
+
assert.equal(evaluateDirtyGate({ before: { source: 'files', entries: { 'x.js': 'h', 'y.js': 'h' } } }).gate, false);
|
|
77
|
+
assert.equal(evaluateDirtyGate({ before: { available: false, entries: {} } }).gate, false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------- A: dirty -> decision -> proceed -> resume -> no re-ask ----------
|
|
81
|
+
test('A: dirty -> decision -> proceed -> resume -> implementation runs once, no second question', async () => {
|
|
82
|
+
const repo = gitRepo({ dirty: ['user.txt'] });
|
|
83
|
+
const path = dbPath();
|
|
84
|
+
const session = openStore(path);
|
|
85
|
+
const { calls, runSubagent } = subagentStub({ writes: ['agent.txt'] });
|
|
86
|
+
const runner = createAgentRunner({ invoke: async () => ({ ok: false, error: 'no' }), runSubagent, agents, routing, registry: REG, repoRoot: repo });
|
|
87
|
+
const first = await runOrch({ repoRoot: repo, runner, plan: [spec('t1', 'coder', [], { kind: 'implement' })], session });
|
|
88
|
+
assert.equal(first.runStatus, 'waiting_for_user');
|
|
89
|
+
assert.equal(first.escalations.length, 1);
|
|
90
|
+
assert.match(first.escalations[0].question, /pre-existing uncommitted/);
|
|
91
|
+
assert.equal(calls.length, 0, 'no subagent launched before the decision');
|
|
92
|
+
assert.ok(first.trace.some(e => e.type === 'gate' && e.action === 'ask'));
|
|
93
|
+
const decisionId = first.escalations[0].id;
|
|
94
|
+
session.close();
|
|
95
|
+
|
|
96
|
+
const again = openStore(path);
|
|
97
|
+
const resumed = await runOrch({ repoRoot: repo, runner, plan: null, session: again, resumeRunId: first.runId, answers: [{ decisionId, answer: 'proceed' }] });
|
|
98
|
+
assert.equal(resumed.status, 'completed', JSON.stringify(resumed.tasks.map(t => [t.id, t.status, t.blockedReason])));
|
|
99
|
+
assert.equal(resumed.escalations.length, 0, 'no second question');
|
|
100
|
+
assert.equal(calls.length, 1, 'implementation ran exactly once');
|
|
101
|
+
assert.equal(again.listDecisions(first.runId, 'pending').length, 0);
|
|
102
|
+
const t = again.loadTasks(first.runId)[0];
|
|
103
|
+
assert.equal(t.decisions[0].optionId, 'proceed');
|
|
104
|
+
assert.ok(t.ownedPaths.includes('agent.txt'));
|
|
105
|
+
assert.ok(resumed.trace.some(e => e.type === 'gate' && e.action === 'proceed'));
|
|
106
|
+
again.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---------- B: dirty -> abort -> terminal, no re-ask ----------
|
|
110
|
+
test('B: dirty -> abort -> task is blocked terminally and never re-asked', async () => {
|
|
111
|
+
const repo = gitRepo({ dirty: ['user.txt'] });
|
|
112
|
+
const path = dbPath();
|
|
113
|
+
const session = openStore(path);
|
|
114
|
+
const { calls, runSubagent } = subagentStub();
|
|
115
|
+
const runner = createAgentRunner({ invoke: async () => ({ ok: false, error: 'no' }), runSubagent, agents, routing, registry: REG, repoRoot: repo });
|
|
116
|
+
const first = await runOrch({ repoRoot: repo, runner, plan: [spec('t1', 'coder', [], { kind: 'implement' }), spec('t2', 'tester', ['t1'], { kind: 'verify' })], session });
|
|
117
|
+
const decisionId = first.escalations[0].id;
|
|
118
|
+
session.close();
|
|
119
|
+
const again = openStore(path);
|
|
120
|
+
const resumed = await runOrch({ repoRoot: repo, runner, plan: null, session: again, resumeRunId: first.runId, answers: [{ decisionId, answer: 'abort' }] });
|
|
121
|
+
assert.equal(resumed.escalations.length, 0);
|
|
122
|
+
assert.equal(calls.length, 0, 'nothing implemented');
|
|
123
|
+
const t1 = resumed.tasks.find(t => t.id === 't1');
|
|
124
|
+
assert.equal(t1.status, 'blocked');
|
|
125
|
+
assert.match(t1.blockedReason, /aborted by user decision/);
|
|
126
|
+
assert.equal(t1.attempts, 2, 'the abort attempt did not retry');
|
|
127
|
+
assert.equal(resumed.tasks.find(t => t.id === 't2').status, 'blocked');
|
|
128
|
+
assert.equal(resumed.runStatus, 'failed');
|
|
129
|
+
assert.ok(resumed.trace.some(e => e.type === 'task-blocked'));
|
|
130
|
+
// Resuming again does not revive the question.
|
|
131
|
+
const third = await runOrch({ repoRoot: repo, runner, plan: null, session: again, resumeRunId: first.runId });
|
|
132
|
+
assert.equal(third.escalations.length, 0);
|
|
133
|
+
assert.equal(calls.length, 0);
|
|
134
|
+
again.close();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ---------- C: coder changed -> TEST_FAILURE -> retry: own changes are not dirty ----------
|
|
138
|
+
test('C: coder edits, fails tests, retries: its own changes do not trigger the gate', async () => {
|
|
139
|
+
const repo = gitRepo();
|
|
140
|
+
const { calls, runSubagent } = subagentStub({ writes: ['src.js'], reply: (_req, n) => (n === 1
|
|
141
|
+
? json({ status: 'failed', summary: 'tests failed: 1 failing', acceptance: [{ id: 'A1', met: false, evidence: 'npm test: 1 failing' }], verification: [{ command: 'npm test', result: 'fail' }] })
|
|
142
|
+
: okResult()) });
|
|
143
|
+
const runner = createAgentRunner({ invoke: async () => ({ ok: false, error: 'no' }), runSubagent, agents, routing, registry: REG, repoRoot: repo });
|
|
144
|
+
const r = await runOrch({ repoRoot: repo, runner, plan: [spec('t1', 'coder', [], { kind: 'implement' })] });
|
|
145
|
+
assert.equal(r.status, 'completed', JSON.stringify(r.tasks[0]));
|
|
146
|
+
assert.equal(calls.length, 2);
|
|
147
|
+
assert.equal(r.escalations.length, 0, 'retry on own dirty tree asked nothing');
|
|
148
|
+
assert.equal(r.tasks[0].attempts, 2);
|
|
149
|
+
assert.deepEqual(r.tasks[0].ownedPaths, ['src.js']);
|
|
150
|
+
assert.ok(!r.trace.some(e => e.type === 'gate'));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------- D: reviewer finding -> rework: original implement changes are owned ----------
|
|
154
|
+
test('D: review finds blocking issue -> rework task runs without the gate on the first implementation\'s changes', async () => {
|
|
155
|
+
const repo = gitRepo();
|
|
156
|
+
const { calls, runSubagent } = subagentStub({ writes: ['impl.js'], reply: req => {
|
|
157
|
+
if (req.taskId === 'rev') return json({ status: 'completed', summary: 'reviewed', acceptance: [{ id: 'A1', met: true, evidence: 'read diff' }], remainingIssues: [{ summary: 'missing null check', blocking: true }] });
|
|
158
|
+
if (req.taskId === 't3') return okResult(); // re-review: clean
|
|
159
|
+
return okResult();
|
|
160
|
+
} });
|
|
161
|
+
// Use the subagent path for reviewer too by making every agent run through the same stub.
|
|
162
|
+
const runner = createAgentRunner({ invoke: async () => ({ ok: false, error: 'no' }), runSubagent, agents, routing, registry: REG, repoRoot: repo });
|
|
163
|
+
const r = await runOrch({ repoRoot: repo, runner, plan: [spec('t1', 'coder', [], { kind: 'implement' }), spec('rev', 'reviewer', ['t1'], { kind: 'review' })], policy: { limits: { max_rework_cycles: 1 } } });
|
|
164
|
+
assert.equal(r.status, 'completed', JSON.stringify(r.tasks.map(t => [t.id, t.kind, t.status, t.blockedReason])));
|
|
165
|
+
assert.equal(r.escalations.length, 0, 'rework asked nothing about the tree the first implement dirtied');
|
|
166
|
+
const rework = r.tasks.find(t => t.kind === 'implement' && t.id !== 't1');
|
|
167
|
+
assert.ok(rework, 'a rework task was added');
|
|
168
|
+
assert.equal(rework.status, 'completed');
|
|
169
|
+
assert.ok(calls.some(c => c.taskId === rework.id));
|
|
170
|
+
assert.ok(!r.trace.some(e => e.type === 'gate'));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---------- E: pre-existing unrelated changes -> gate before implementation ----------
|
|
174
|
+
test('E: pre-existing unrelated dirty files gate implementation (session-less run -> blocked)', async () => {
|
|
175
|
+
const repo = gitRepo({ dirty: ['unrelated.txt'] });
|
|
176
|
+
const { calls, runSubagent } = subagentStub();
|
|
177
|
+
const runner = createAgentRunner({ invoke: async () => ({ ok: false, error: 'no' }), runSubagent, agents, routing, registry: REG, repoRoot: repo });
|
|
178
|
+
const r = await runOrch({ repoRoot: repo, runner, plan: [spec('t1', 'coder', [], { kind: 'implement' })] });
|
|
179
|
+
assert.equal(calls.length, 0);
|
|
180
|
+
assert.equal(r.status, 'needs-user');
|
|
181
|
+
assert.equal(r.tasks[0].status, 'blocked');
|
|
182
|
+
assert.match(r.escalations[0].question, /unrelated\.txt/);
|
|
183
|
+
assert.deepEqual(r.escalations[0].options.map(o => o.id), ['proceed', 'abort']);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ---------- F: read-only investigate on a dirty tree continues ----------
|
|
187
|
+
test('F: read-only investigation on a dirty tree is not gated', async () => {
|
|
188
|
+
const repo = gitRepo({ dirty: ['user.txt'] });
|
|
189
|
+
const { calls, runSubagent } = subagentStub();
|
|
190
|
+
const runner = createAgentRunner({ invoke: async () => ({ ok: false, error: 'no' }), runSubagent, agents, routing, registry: REG, repoRoot: repo });
|
|
191
|
+
const r = await runOrch({ repoRoot: repo, runner, plan: [spec('s', 'scout', [], { kind: 'investigate', executionMode: 'subagent' }), spec('rv', 'reviewer', ['s'], { kind: 'review' })] });
|
|
192
|
+
assert.equal(r.status, 'completed', JSON.stringify(r.tasks.map(t => [t.id, t.status, t.blockedReason])));
|
|
193
|
+
assert.equal(calls.length, 2);
|
|
194
|
+
assert.equal(r.escalations.length, 0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ---------- G: non-git workspace is never gated by inventory count ----------
|
|
198
|
+
test('G: non-git workspace: file inventory count is not a dirty count', async () => {
|
|
199
|
+
const repo = mkdtempSync(join(tmpdir(), 'ludi-gate-nogit-'));
|
|
200
|
+
for (let i = 0; i < 12; i++) writeFileSync(join(repo, `f${i}.txt`), 'x\n');
|
|
201
|
+
const before = captureWorktree(repo);
|
|
202
|
+
assert.equal(before.source, 'files');
|
|
203
|
+
assert.ok(Object.keys(before.entries).length >= 12);
|
|
204
|
+
const { calls, runSubagent } = subagentStub({ writes: ['out.js'] });
|
|
205
|
+
const runner = createAgentRunner({ invoke: async () => ({ ok: false, error: 'no' }), runSubagent, agents, routing, registry: REG, repoRoot: repo });
|
|
206
|
+
const r = await runOrch({ repoRoot: repo, runner, plan: [spec('t1', 'coder', [], { kind: 'implement' })] });
|
|
207
|
+
assert.equal(r.status, 'completed', JSON.stringify(r.tasks[0]));
|
|
208
|
+
assert.equal(calls.length, 1);
|
|
209
|
+
assert.equal(r.escalations.length, 0);
|
|
210
|
+
assert.ok(!r.trace.some(e => e.type === 'gate'));
|
|
211
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Opt-in real E2E for the browser capability: drives the *installed* agent-browser
|
|
2
|
+
// CLI against the local fixture page, snapshot -> ref action -> snapshot.
|
|
3
|
+
// Not part of `node --test`; it launches a real browser. Run:
|
|
4
|
+
// node tests/e2e-browser.mjs
|
|
5
|
+
// Requires `agent-browser` on PATH (npm i -g agent-browser) and a local Chrome/Brave.
|
|
6
|
+
import { resolve, dirname, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { runBrowser, locateAgentBrowser } from '../adapters/pi/browser/agent-browser.mjs';
|
|
10
|
+
|
|
11
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
const fixture = pathToFileURL(join(kit, 'tests/fixtures/browser-page/index.html')).href;
|
|
13
|
+
const session = `ludi-e2e-${Date.now()}`;
|
|
14
|
+
const env = { ...process.env };
|
|
15
|
+
|
|
16
|
+
assert.ok(locateAgentBrowser(env), 'agent-browser binary not found; npm i -g agent-browser');
|
|
17
|
+
|
|
18
|
+
const steps = [];
|
|
19
|
+
const step = async (name, promise) => {
|
|
20
|
+
const res = await promise;
|
|
21
|
+
steps.push({ name, ok: res.ok, kind: res.kind, status: res.status, durationMs: res.durationMs });
|
|
22
|
+
if (!res.ok) { console.log(JSON.stringify({ steps, failed: name, stderr: res.stderr, stdout: res.stdout, error: res.error }, null, 2)); process.exit(1); }
|
|
23
|
+
return res;
|
|
24
|
+
};
|
|
25
|
+
const opt = { env, session, timeoutMs: 90000 };
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// 1. open the local page
|
|
29
|
+
await step('open', runBrowser('open', { url: fixture }, opt));
|
|
30
|
+
|
|
31
|
+
// 2. snapshot -> interactive refs
|
|
32
|
+
const snap1 = await step('snapshot', runBrowser('snapshot', {}, opt));
|
|
33
|
+
const refs = [...snap1.stdout.matchAll(/@?e\d+/g)].map(m => m[0]);
|
|
34
|
+
assert.ok(refs.length >= 2, `expected >=2 interactive refs, got: ${snap1.stdout.slice(0, 500)}`);
|
|
35
|
+
|
|
36
|
+
// find the input ref (textbox) and the button ref from the snapshot text
|
|
37
|
+
const refOf = re => { const m = snap1.stdout.match(re); return m ? '@' + m[1] : null; };
|
|
38
|
+
const inputRef = refOf(/textbox[^\n]*ref=(e\d+)/i) ?? refOf(/input[^\n]*ref=(e\d+)/i);
|
|
39
|
+
const buttonRef = refOf(/button[^\n]*ref=(e\d+)/i);
|
|
40
|
+
assert.ok(inputRef, `no textbox ref in snapshot:\n${snap1.stdout.slice(0, 800)}`);
|
|
41
|
+
assert.ok(buttonRef, `no button ref in snapshot:\n${snap1.stdout.slice(0, 800)}`);
|
|
42
|
+
|
|
43
|
+
// 3. fill the input via its ref
|
|
44
|
+
await step('fill', runBrowser('fill', { ref: inputRef, text: 'hello' }, opt));
|
|
45
|
+
|
|
46
|
+
// 4. click the button via its ref
|
|
47
|
+
await step('click', runBrowser('click', { ref: buttonRef }, opt));
|
|
48
|
+
|
|
49
|
+
// 5. re-snapshot and read the result text
|
|
50
|
+
const snap2 = await step('snapshot-after', runBrowser('snapshot', {}, opt));
|
|
51
|
+
const resultRef = (snap2.stdout.match(/result[^\n]*ref=(e\d+)/i))?.[1];
|
|
52
|
+
// result <p> is not interactive, so read it by selector if no ref; use get text on the id
|
|
53
|
+
const textRes = resultRef
|
|
54
|
+
? await step('getText', runBrowser('getText', { ref: resultRef }, opt))
|
|
55
|
+
: await step('getText-sel', runBrowser('getText', { ref: '#result' }, opt));
|
|
56
|
+
assert.match(textRes.stdout, /hello hello|hello\s+hello/i, `expected greeting, got: ${textRes.stdout.slice(0, 300)}`);
|
|
57
|
+
|
|
58
|
+
// 6. confirm the input kept the filled value
|
|
59
|
+
const val = await step('getValue', runBrowser('getValue', { ref: inputRef }, opt));
|
|
60
|
+
assert.match(val.stdout, /hello/);
|
|
61
|
+
|
|
62
|
+
console.log(JSON.stringify({ outcome: 'success', fixture, session, steps }, null, 2));
|
|
63
|
+
} finally {
|
|
64
|
+
await runBrowser('close', {}, opt);
|
|
65
|
+
}
|
|
66
|
+
console.log('PASS: real browser E2E');
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Opt-in real resume through the installed pi CLI (not part of `node --test`; spends a little quota).
|
|
2
|
+
// node tests/e2e-real-orchestrator-resume.mjs
|
|
3
|
+
// Starts a process that completes scout, stops on an intentional user decision, then a second
|
|
4
|
+
// process answers and resumes. The completed scout task must not run again.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { mkdtempSync, cpSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, resolve, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
12
|
+
import { loadRegistry } from '../lib/registry.mjs';
|
|
13
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
14
|
+
import { loadPolicy, mergePolicy } from '../lib/orchestrator/policy.mjs';
|
|
15
|
+
import { openStore } from '../lib/orchestrator/store.mjs';
|
|
16
|
+
import { createHealthMonitor } from '../lib/orchestrator/health.mjs';
|
|
17
|
+
import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
|
|
18
|
+
import { orchestrate, formatReport } from '../lib/orchestrator/orchestrator.mjs';
|
|
19
|
+
import { createPiInvoker } from '../adapters/pi/lib/invoke.mjs';
|
|
20
|
+
|
|
21
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const here = fileURLToPath(import.meta.url);
|
|
23
|
+
const phase = process.argv[2] ?? 'all';
|
|
24
|
+
|
|
25
|
+
const plan = [
|
|
26
|
+
{ id: 't1', title: 'Locate the average() defect', goal: 'Find why the average() test fails in this repository. Do not change files.', agent: 'scout', kind: 'investigate', capability: 'cheap-code', dependencies: [],
|
|
27
|
+
acceptance: ['the file and function containing the defect are named', 'the cause of the failing test is stated'] },
|
|
28
|
+
{ id: 't2', title: 'Ask whether to apply the fix', goal: [
|
|
29
|
+
'Do not edit files.',
|
|
30
|
+
'If the orchestrator already recorded a decision for "Apply the average() fix now?", return status "completed", quote that decision, and do not ask again.',
|
|
31
|
+
'If no decision is recorded, do not choose. Return status "blocked" with exactly one decision and no acceptance met:',
|
|
32
|
+
'{"key":"apply-fix","question":"Apply the average() fix now?","flags":["user_value_judgement"],"options":[{"id":"now","summary":"apply the fix now","reversible":false,"risk":"medium"},{"id":"later","summary":"leave the fixture for now","reversible":false,"risk":"medium"}]}',
|
|
33
|
+
].join('\n'), agent: 'scout', kind: 'investigate', capability: 'cheap-code', dependencies: ['t1'],
|
|
34
|
+
acceptance: ['the user has chosen whether to apply the fix'] },
|
|
35
|
+
{ id: 't3', title: 'Confirm the recorded decision', goal: 'Quote the user decision already recorded for t2. Do not change files. Do not ask the user anything.', agent: 'reviewer', kind: 'review', capability: 'cheap-code', dependencies: ['t2'],
|
|
36
|
+
acceptance: ['the user decision from t2 is quoted'] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function context(repo) {
|
|
40
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
41
|
+
const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
|
|
42
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
43
|
+
const { policy } = loadPolicy(join(kit, 'orchestration/decision-policy.json'));
|
|
44
|
+
// Stay on cheap-code so a hosted usage limit falls through to the local candidate instead of the hosted-only ladder.
|
|
45
|
+
return { routing, registry, agents, policy: mergePolicy(policy, { decision_policy: { reassign_on_failure: false } }) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (phase === 'all') {
|
|
49
|
+
const root = mkdtempSync(join(tmpdir(), 'ludi-real-resume-'));
|
|
50
|
+
const repo = join(root, 'repo');
|
|
51
|
+
const store = join(root, 'state.db');
|
|
52
|
+
cpSync(join(kit, 'tests/fixtures/math-repo'), repo, { recursive: true });
|
|
53
|
+
const start = spawnSync(process.execPath, [here, 'start', store, repo], { encoding: 'utf8', timeout: 300000 });
|
|
54
|
+
if (start.status !== 0) { console.error(start.stdout); console.error(start.stderr); process.exit(start.status ?? 1); }
|
|
55
|
+
const info = JSON.parse(start.stdout.trim().split(/\r?\n/).at(-1));
|
|
56
|
+
console.log('start', info);
|
|
57
|
+
assert.equal(info.runStatus, 'waiting_for_user');
|
|
58
|
+
assert.equal(info.tasks.find(t => t.id === 't1').status, 'completed');
|
|
59
|
+
assert.equal(info.tasks.find(t => t.id === 't2').status, 'waiting_for_user');
|
|
60
|
+
assert.equal(info.tasks.find(t => t.id === 't3').status, 'blocked');
|
|
61
|
+
const resume = spawnSync(process.execPath, [here, 'resume', store, repo, info.runId, info.decisionId], { encoding: 'utf8', timeout: 300000 });
|
|
62
|
+
if (resume.status !== 0) { console.error(resume.stdout); console.error(resume.stderr); process.exit(resume.status ?? 1); }
|
|
63
|
+
console.log(resume.stdout);
|
|
64
|
+
console.log('PASS: real pi orchestrator resume');
|
|
65
|
+
} else if (phase === 'start') {
|
|
66
|
+
const [, , , store, repo] = process.argv;
|
|
67
|
+
const { routing, registry, agents, policy } = context(repo);
|
|
68
|
+
const session = openStore(store);
|
|
69
|
+
const health = createHealthMonitor({ session, policy });
|
|
70
|
+
const runner = createAgentRunner({ invoke: createPiInvoker(), agents, routing, registry, repoRoot: repo, maxModelAttempts: policy.limits.model_attempts_per_task, health });
|
|
71
|
+
try {
|
|
72
|
+
const result = await orchestrate({ request: 'Diagnose the failing average() test and stop before applying a fix', plan, agents, routing, registry, policy, runner, repoRoot: repo, session, health });
|
|
73
|
+
writeFileSync(join(repo, 'start-trace.json'), JSON.stringify(result, null, 2));
|
|
74
|
+
console.log(formatReport(result));
|
|
75
|
+
console.log(JSON.stringify({ runId: result.runId, runStatus: result.runStatus, decisionId: result.escalations[0]?.id, tasks: result.tasks.map(t => ({ id: t.id, status: t.status })) }));
|
|
76
|
+
if (result.runStatus !== 'waiting_for_user') process.exitCode = 1;
|
|
77
|
+
} finally { session.close(); }
|
|
78
|
+
} else if (phase === 'resume') {
|
|
79
|
+
const [, , , store, repo, runId, decisionId] = process.argv;
|
|
80
|
+
const { routing, registry, agents, policy } = context(repo);
|
|
81
|
+
const session = openStore(store);
|
|
82
|
+
const before = session.loadTrace(runId).filter(e => e.type === 'result' && e.taskId === 't1').length;
|
|
83
|
+
const health = createHealthMonitor({ session, policy });
|
|
84
|
+
const runner = createAgentRunner({ invoke: createPiInvoker(), agents, routing, registry, repoRoot: repo, maxModelAttempts: policy.limits.model_attempts_per_task, health });
|
|
85
|
+
try {
|
|
86
|
+
const result = await orchestrate({
|
|
87
|
+
request: '', resumeRunId: runId, answers: [{ decisionId, answer: 'later' }],
|
|
88
|
+
agents, routing, registry, policy, runner, repoRoot: repo, session, health,
|
|
89
|
+
});
|
|
90
|
+
const t1Runs = result.trace.filter(e => e.type === 'result' && e.taskId === 't1');
|
|
91
|
+
writeFileSync(join(repo, 'resume-trace.json'), JSON.stringify({ status: result.status, runStatus: result.runStatus, tasks: result.tasks, t1Runs: t1Runs.length }, null, 2));
|
|
92
|
+
console.log(formatReport(result));
|
|
93
|
+
assert.equal(before, 1);
|
|
94
|
+
assert.equal(t1Runs.length, 1, 'completed scout task was executed again');
|
|
95
|
+
assert.equal(result.tasks.find(t => t.id === 't3').status, 'completed');
|
|
96
|
+
assert.equal(result.status, 'completed');
|
|
97
|
+
} finally { session.close(); }
|
|
98
|
+
} else {
|
|
99
|
+
console.error(`unknown phase ${phase}`);
|
|
100
|
+
process.exit(2);
|
|
101
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Opt-in real orchestrator run through the installed pi CLI (not part of `node --test`; spends a little quota).
|
|
2
|
+
// node tests/e2e-real-orchestrator.mjs
|
|
3
|
+
// Two read-only tasks on the math fixture, both routed to the cheap-code capability to keep cost low:
|
|
4
|
+
// scout investigates, reviewer (role kept, model route overridden) checks the finding. No files are written.
|
|
5
|
+
import { mkdtempSync, cpSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
11
|
+
import { loadRegistry } from '../lib/registry.mjs';
|
|
12
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
13
|
+
import { loadPolicy } from '../lib/orchestrator/policy.mjs';
|
|
14
|
+
import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
|
|
15
|
+
import { orchestrate, formatReport } from '../lib/orchestrator/orchestrator.mjs';
|
|
16
|
+
import { createPiInvoker } from '../adapters/pi/lib/invoke.mjs';
|
|
17
|
+
|
|
18
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
20
|
+
const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
|
|
21
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
22
|
+
const { policy } = loadPolicy(join(kit, 'orchestration/decision-policy.json'));
|
|
23
|
+
const repo = mkdtempSync(join(tmpdir(), 'ludi-real-orch-'));
|
|
24
|
+
cpSync(join(kit, 'tests/fixtures/math-repo'), repo, { recursive: true });
|
|
25
|
+
|
|
26
|
+
const plan = [
|
|
27
|
+
{ id: 't1', title: 'Locate the average() defect', goal: 'Find why the average() test fails in this repository. Do not change files.', agent: 'scout', kind: 'investigate', capability: 'cheap-code', dependencies: [],
|
|
28
|
+
acceptance: ['the file and function containing the defect are named', 'the cause of the failing test is stated'] },
|
|
29
|
+
{ id: 't2', title: 'Check the diagnosis', goal: 'Check whether the diagnosis from t1 is correct against the source. Do not change files.', agent: 'reviewer', kind: 'review', capability: 'cheap-code', dependencies: ['t1'],
|
|
30
|
+
acceptance: ['the diagnosis is confirmed or refuted with a code reference'] },
|
|
31
|
+
];
|
|
32
|
+
const runner = createAgentRunner({ invoke: createPiInvoker(), agents, routing, registry, repoRoot: repo, maxModelAttempts: policy.limits.model_attempts_per_task });
|
|
33
|
+
const result = await orchestrate({ request: 'Diagnose the failing average() test', plan, agents, routing, registry, policy, runner, repoRoot: repo });
|
|
34
|
+
const traceFile = join(repo, 'orchestration-trace.json');
|
|
35
|
+
writeFileSync(traceFile, JSON.stringify(result, null, 2));
|
|
36
|
+
console.log(formatReport(result));
|
|
37
|
+
console.log(JSON.stringify({ trace: traceFile, runs: result.trace.filter(e => e.type === 'result').map(e => ({ task: e.taskId, attempt: e.attempt, modelId: e.modelId, verdict: e.verdict, reasons: e.reasons, ms: e.steps?.reduce((s, x) => s + (x.durationMs ?? 0), 0) })) }, null, 2));
|
|
38
|
+
assert.equal(result.tasks.length, 2);
|
|
39
|
+
assert.ok(result.tasks.every(t => ['completed', 'failed', 'blocked'].includes(t.status)), 'every task reached a terminal state');
|
|
40
|
+
assert.equal(result.status, 'completed');
|
|
41
|
+
console.log('PASS: real pi orchestrator E2E');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Opt-in real E2E through the installed pi CLI and the machine-local model registry.
|
|
2
|
+
// Not part of `node --test`; it spends real model quota. Run:
|
|
3
|
+
// node tests/e2e-real-pi.mjs
|
|
4
|
+
// Requires adapters/pi/models.local.json with ready providers (`pi auth check --provider <p>`).
|
|
5
|
+
import { mkdtempSync, cpSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve, dirname } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
11
|
+
import { loadRegistry } from '../lib/registry.mjs';
|
|
12
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
13
|
+
import { runPipeline, runTests } from '../lib/pipeline.mjs';
|
|
14
|
+
import { createPiInvoker } from '../adapters/pi/lib/invoke.mjs';
|
|
15
|
+
|
|
16
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
18
|
+
const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
|
|
19
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
20
|
+
const repo = mkdtempSync(join(tmpdir(), 'ludi-real-e2e-'));
|
|
21
|
+
cpSync(join(kit, 'tests/fixtures/math-repo'), repo, { recursive: true });
|
|
22
|
+
assert.equal(runTests(repo, 'npm test').ok, false, 'fixture must start red');
|
|
23
|
+
const summary = await runPipeline({ repoRoot: repo, task: 'Fix the failing average() test in this repository', routing, registry, agents, invoke: createPiInvoker(), outDir: join(repo, '.pipeline-out') });
|
|
24
|
+
console.log(JSON.stringify({ outcome: summary.outcome, steps: summary.steps.map(s => ({ step: s.step, backend: s.backend, modelId: s.modelId, ok: s.ok, durationMs: s.durationMs })), applied: summary.applied, out: join(repo, '.pipeline-out') }, null, 2));
|
|
25
|
+
assert.equal(summary.outcome, 'success');
|
|
26
|
+
assert.equal(runTests(repo, 'npm test').ok, true);
|
|
27
|
+
console.log('PASS: real pi E2E');
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Opt-in tool-capable run through the installed pi CLI (not part of `node --test`).
|
|
2
|
+
// node tests/e2e-real-tool-orchestrator.mjs
|
|
3
|
+
// Scout reads the fixture, coder edits average(), tester runs the tests, reviewer checks the diff.
|
|
4
|
+
// All four stay on cheap-code so a hosted usage limit can fall through to the local model.
|
|
5
|
+
import { mkdtempSync, cpSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join, resolve, dirname } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { loadRouting } from '../lib/routing.mjs';
|
|
12
|
+
import { loadRegistry } from '../lib/registry.mjs';
|
|
13
|
+
import { loadAgents } from '../lib/agents.mjs';
|
|
14
|
+
import { loadPolicy, mergePolicy } from '../lib/orchestrator/policy.mjs';
|
|
15
|
+
import { openStore } from '../lib/orchestrator/store.mjs';
|
|
16
|
+
import { createHealthMonitor } from '../lib/orchestrator/health.mjs';
|
|
17
|
+
import { createAgentRunner } from '../lib/orchestrator/runner.mjs';
|
|
18
|
+
import { orchestrate, formatReport } from '../lib/orchestrator/orchestrator.mjs';
|
|
19
|
+
import { createPiInvoker } from '../adapters/pi/lib/invoke.mjs';
|
|
20
|
+
import { createPiSubagentRunner } from '../adapters/pi/lib/subagent.mjs';
|
|
21
|
+
|
|
22
|
+
const kit = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const root = mkdtempSync(join(tmpdir(), 'ludi-real-tools-'));
|
|
24
|
+
const repo = join(root, 'repo');
|
|
25
|
+
const storePath = join(root, 'state.db');
|
|
26
|
+
cpSync(join(kit, 'tests/fixtures/math-repo'), repo, { recursive: true });
|
|
27
|
+
for (const args of [['init'], ['add', '-A'], ['-c', 'user.email=ludi@example.com', '-c', 'user.name=ludi', 'commit', '-m', 'fixture']]) {
|
|
28
|
+
const git = spawnSync('git', args, { cwd: repo, encoding: 'utf8' });
|
|
29
|
+
if (git.status !== 0) { console.error(git.stderr); process.exit(git.status ?? 1); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const plan = [
|
|
33
|
+
{ id: 't1', title: 'Find the average() defect', goal: 'Read the repository and name the file, function, and cause of the failing average() test. Do not edit files.', agent: 'scout', kind: 'investigate', capability: 'cheap-code', executionMode: 'subagent', dependencies: [], acceptance: ['the file and function are named', 'the cause is stated from the source'] },
|
|
34
|
+
{ id: 't2', title: 'Fix average()', goal: 'Change average() so it divides by values.length. Do not change tests or unrelated files.', agent: 'coder', kind: 'implement', capability: 'cheap-code', executionMode: 'subagent', dependencies: ['t1'], acceptance: ['src/math.js divides by values.length', 'no other source file was rewritten'] },
|
|
35
|
+
{ id: 't3', title: 'Run the tests', goal: 'Run npm test in this repository and report the result. Do not edit files.', agent: 'tester', kind: 'verify', capability: 'cheap-code', executionMode: 'subagent', dependencies: ['t2'], acceptance: ['npm test was executed', 'the command exited successfully'] },
|
|
36
|
+
{ id: 't4', title: 'Review the diff', goal: 'Review the uncommitted diff against the goal. Do not edit files or push.', agent: 'reviewer', kind: 'review', capability: 'cheap-code', executionMode: 'subagent', dependencies: ['t3'], acceptance: ['the diff matches the average() fix', 'no publish or destructive command is proposed'] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const routing = loadRouting(join(kit, 'routing/routing.json'));
|
|
40
|
+
const { registry } = loadRegistry(join(kit, 'adapters/pi/models.json'), join(kit, 'adapters/pi/models.local.json'), routing);
|
|
41
|
+
const { agents } = loadAgents(join(kit, 'agents'), routing);
|
|
42
|
+
const { policy } = loadPolicy(join(kit, 'orchestration/decision-policy.json'));
|
|
43
|
+
const active = mergePolicy(policy, { decision_policy: { reassign_on_failure: false }, limits: { max_retries: 1 }, agent_runtime: { max_runtime_ms: 180000, max_tool_calls: 25, max_turns: 8 } });
|
|
44
|
+
const session = openStore(storePath);
|
|
45
|
+
const health = createHealthMonitor({ session, policy: active });
|
|
46
|
+
const runner = createAgentRunner({
|
|
47
|
+
invoke: createPiInvoker(), runSubagent: createPiSubagentRunner(), agents, routing, registry, repoRoot: repo, policy: active, health,
|
|
48
|
+
maxModelAttempts: active.limits.model_attempts_per_task,
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
const result = await orchestrate({ request: 'Fix the failing average() test', plan, agents, routing, registry, policy: active, runner, repoRoot: repo, session, health });
|
|
52
|
+
writeFileSync(join(root, 'trace.json'), JSON.stringify(result, null, 2));
|
|
53
|
+
console.log(formatReport(result));
|
|
54
|
+
const children = result.trace.filter(e => e.type === 'child');
|
|
55
|
+
console.log(JSON.stringify({ runId: result.runId, status: result.status, children: children.map(c => ({ task: c.taskId, id: c.childSessionId, tools: c.toolCalls, turns: c.turns, status: c.status })), tasks: result.tasks.map(t => ({ id: t.id, status: t.status, files: t.result?.filesChanged ?? [] })) }, null, 2));
|
|
56
|
+
assert.equal(result.status, 'completed');
|
|
57
|
+
assert.equal(result.escalations.length, 0);
|
|
58
|
+
assert.ok(children.length >= 4, 'child sessions missing');
|
|
59
|
+
assert.ok(children.some(c => (c.toolCalls ?? 0) > 0), 'no tool call was recorded');
|
|
60
|
+
assert.ok(result.tasks.find(t => t.id === 't2').result.filesChanged.some(f => f.includes('math.js')));
|
|
61
|
+
const tests = spawnSync('npm', ['test'], { cwd: repo, encoding: 'utf8', shell: true });
|
|
62
|
+
assert.equal(tests.status, 0, tests.stdout + tests.stderr);
|
|
63
|
+
console.log('PASS: real pi tool-capable E2E');
|
|
64
|
+
} finally {
|
|
65
|
+
session.close();
|
|
66
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>ludi browser fixture</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<h1>Browser fixture</h1>
|
|
9
|
+
<label for="name">Name</label>
|
|
10
|
+
<input id="name" name="name" type="text" placeholder="type a name">
|
|
11
|
+
<button id="greet" type="button">Greet</button>
|
|
12
|
+
<p id="result" aria-live="polite"></p>
|
|
13
|
+
<script>
|
|
14
|
+
document.getElementById('greet').addEventListener('click', () => {
|
|
15
|
+
const v = document.getElementById('name').value;
|
|
16
|
+
document.getElementById('result').textContent = v ? `hello ${v}` : 'hello (empty)';
|
|
17
|
+
});
|
|
18
|
+
</script>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Fixture catalog for the Qoder/Qwen3.8-Flash scenario. Ids are fictional test data.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"updatedAt": "2026-03-02",
|
|
5
|
+
"models": [
|
|
6
|
+
{
|
|
7
|
+
"provider": "qoder",
|
|
8
|
+
"model": "Qwen3.8-Flash",
|
|
9
|
+
"status": "free-campaign",
|
|
10
|
+
"cost": { "free": true },
|
|
11
|
+
"postCampaignCost": { "usdPerMInput": 0.3, "usdPerMOutput": 1.2, "free": false },
|
|
12
|
+
"contextK": 256,
|
|
13
|
+
"vision": false,
|
|
14
|
+
"toolUse": "good",
|
|
15
|
+
"thinking": "low",
|
|
16
|
+
"scores": { "coding": 62, "reasoning": 55, "speed": 85 }
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"provider": "qoder",
|
|
20
|
+
"model": "Qwen3.9-Flash",
|
|
21
|
+
"status": "active",
|
|
22
|
+
"cost": { "usdPerMInput": 0.25, "usdPerMOutput": 1.0 },
|
|
23
|
+
"contextK": 256,
|
|
24
|
+
"vision": false,
|
|
25
|
+
"toolUse": "good",
|
|
26
|
+
"thinking": "low",
|
|
27
|
+
"scores": { "coding": 70, "reasoning": 62, "speed": 85 }
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"provider": "openai-codex",
|
|
31
|
+
"model": "gpt-5.6-luna",
|
|
32
|
+
"status": "active",
|
|
33
|
+
"cost": { "usdPerMInput": 0.4, "usdPerMOutput": 1.6 },
|
|
34
|
+
"contextK": 272,
|
|
35
|
+
"vision": false,
|
|
36
|
+
"toolUse": "good",
|
|
37
|
+
"thinking": "low",
|
|
38
|
+
"scores": { "coding": 68, "reasoning": 60, "speed": 80 }
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"provider": "openai-codex",
|
|
42
|
+
"model": "gpt-5.6-sol",
|
|
43
|
+
"status": "active",
|
|
44
|
+
"cost": { "usdPerMInput": 1.5, "usdPerMOutput": 6.0 },
|
|
45
|
+
"contextK": 400,
|
|
46
|
+
"vision": false,
|
|
47
|
+
"toolUse": "good",
|
|
48
|
+
"thinking": "medium",
|
|
49
|
+
"scores": { "coding": 85, "reasoning": 82, "speed": 55 }
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"provider": "openai-codex",
|
|
53
|
+
"model": "gpt-6-astra",
|
|
54
|
+
"status": "active",
|
|
55
|
+
"cost": { "usdPerMInput": 2.5, "usdPerMOutput": 10.0 },
|
|
56
|
+
"contextK": 400,
|
|
57
|
+
"vision": true,
|
|
58
|
+
"toolUse": "good",
|
|
59
|
+
"thinking": "medium",
|
|
60
|
+
"scores": { "coding": 88, "reasoning": 90, "speed": 50 }
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"provider": "openai-codex",
|
|
64
|
+
"model": "gpt-5.5",
|
|
65
|
+
"status": "active",
|
|
66
|
+
"cost": { "usdPerMInput": 1.25, "usdPerMOutput": 5.0 },
|
|
67
|
+
"contextK": 400,
|
|
68
|
+
"vision": false,
|
|
69
|
+
"toolUse": "good",
|
|
70
|
+
"thinking": "high",
|
|
71
|
+
"scores": { "coding": 82, "reasoning": 84, "speed": 45 }
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Fixture: the Qoder provider ends the free campaign for Qwen3.8-Flash on 2026-03-01. Used by tests/maintenance.test.mjs; ids are fictional.",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"events": [
|
|
5
|
+
{
|
|
6
|
+
"type": "free-campaign-ended",
|
|
7
|
+
"provider": "qoder",
|
|
8
|
+
"model": "Qwen3.8-Flash",
|
|
9
|
+
"asOf": "2026-03-01",
|
|
10
|
+
"note": "Qoder free promotion for Qwen3.8-Flash ends; pay-as-you-go pricing applies afterwards."
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|