wisp-agents 0.18.0 → 0.20.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/CHANGELOG.md +51 -1
- package/README.md +19 -7
- package/package.json +2 -2
- package/server/adapters/antigravity.js +2 -1
- package/server/adapters/claude.js +2 -1
- package/server/adapters/codex.js +2 -1
- package/server/adapters/commands.js +808 -0
- package/server/adapters/cursor.js +2 -1
- package/server/adapters/grok.js +2 -1
- package/server/adapters/opencode.js +2 -1
- package/server/check.js +205 -0
- package/server/index.js +2 -1
- package/server/judge.js +78 -0
- package/server/openapi.js +3 -3
- package/server/pipeline.js +217 -0
- package/server/quick.js +76 -0
- package/server/router.js +201 -28
- package/server/routes/auto.js +8 -1
- package/server/routes/settings.js +2 -0
- package/server/routes/ship.js +9 -4
- package/server/runner.js +59 -13
- package/server/scout.js +71 -0
- package/server/services/auto.js +103 -14
- package/server/services/launch.js +11 -6
- package/server/services/ship.js +36 -14
- package/web/css/auto.css +22 -0
- package/web/css/ship.css +5 -1
- package/web/index.html +1 -0
- package/web/js/blocks.js +1 -1
- package/web/js/features/actions.js +2 -4
- package/web/js/features/auto.js +38 -7
- package/web/js/features/composer.js +2 -4
- package/web/js/features/feed.js +31 -1
- package/web/js/features/live.js +2 -7
- package/web/js/features/projects.js +2 -5
- package/web/js/features/runners.js +7 -6
- package/web/js/features/settings.js +34 -8
- package/web/js/features/ship.js +30 -3
- package/web/js/features/turns.js +4 -3
- package/web/js/ui/popover.js +10 -0
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// ask_user tool isn't available here; the protocol tells the agent to end with a question block.
|
|
5
5
|
|
|
6
6
|
import { attachmentNote, clip, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
|
|
7
|
+
import { describeCommand } from './commands.js';
|
|
7
8
|
|
|
8
9
|
const BIN = process.env.WISP_CURSOR_BIN || 'cursor-agent';
|
|
9
10
|
|
|
@@ -130,7 +131,7 @@ function describeTool(name, a, cwd) {
|
|
|
130
131
|
case 'deleteToolCall':
|
|
131
132
|
return { kind: 'edit', label: `Deleting ${f}`, file: f };
|
|
132
133
|
case 'shellToolCall':
|
|
133
|
-
return { kind: 'run', label: a.description ? clip(a.description) :
|
|
134
|
+
return { kind: 'run', label: a.description ? clip(a.description) : describeCommand(a.command, cwd), detail: a.command };
|
|
134
135
|
case 'grepToolCall':
|
|
135
136
|
return { kind: 'search', label: `Searching for “${clip(a.pattern, 40)}”` };
|
|
136
137
|
case 'globToolCall':
|
package/server/adapters/grok.js
CHANGED
|
@@ -10,6 +10,7 @@ import fs from 'node:fs';
|
|
|
10
10
|
import os from 'node:os';
|
|
11
11
|
import path from 'node:path';
|
|
12
12
|
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, flatten, quietOutput, rel, spawnJsonl } from './common.js';
|
|
13
|
+
import { describeCommand } from './commands.js';
|
|
13
14
|
|
|
14
15
|
const BIN = process.env.WISP_GROK_BIN || 'grok';
|
|
15
16
|
|
|
@@ -195,7 +196,7 @@ function describeTool(name, input, cwd) {
|
|
|
195
196
|
return { kind: 'edit', label: `Writing ${f}`, file: f };
|
|
196
197
|
case 'run_terminal_command':
|
|
197
198
|
case 'run_terminal_cmd':
|
|
198
|
-
return { kind: 'run', label: input.description ? clip(input.description) :
|
|
199
|
+
return { kind: 'run', label: input.description ? clip(input.description) : describeCommand(input.command, cwd), detail: input.command };
|
|
199
200
|
case 'grep':
|
|
200
201
|
return { kind: 'search', label: `Searching for “${clip(input.pattern, 40)}”` };
|
|
201
202
|
case 'list_dir':
|
|
@@ -5,6 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { toolsDir } from '../util.js';
|
|
7
7
|
import { ASK_TIMEOUT_MS, attachmentNote, clip, detectVersion, exitText, fileTracker, quietOutput, rel, spawnJsonl, withProtocol } from './common.js';
|
|
8
|
+
import { describeCommand } from './commands.js';
|
|
8
9
|
|
|
9
10
|
const exe = process.platform === 'win32' ? 'opencode.cmd' : 'opencode';
|
|
10
11
|
// Where opencode lives when it isn't on PATH: the copy wisp installs (POST /agents/install),
|
|
@@ -213,7 +214,7 @@ function describeTool(name, input, cwd) {
|
|
|
213
214
|
return { kind: 'edit', label: `Writing ${f}`, file: f };
|
|
214
215
|
case 'bash':
|
|
215
216
|
case 'shell':
|
|
216
|
-
return { kind: 'run', label: input.description ? clip(input.description) :
|
|
217
|
+
return { kind: 'run', label: input.description ? clip(input.description) : describeCommand(input.command, cwd), detail: input.command };
|
|
217
218
|
case 'grep':
|
|
218
219
|
return { kind: 'search', label: `Searching for “${clip(input.pattern, 40)}”` };
|
|
219
220
|
case 'glob':
|
package/server/check.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// wisp's own test check: after an Auto turn, wisp runs the project's tests itself instead of taking
|
|
2
|
+
// the agent's word for it (in the wisp-bench pilot, 19 of 20 failed runs still said "done").
|
|
3
|
+
//
|
|
4
|
+
// detectTests(dir, project) finds the command; runCheck(dir, command) runs it and reads which tests
|
|
5
|
+
// failed from the usual reporters (node --test, jest/vitest, pytest, go, cargo, rspec). A failure
|
|
6
|
+
// only counts against the agent when it's new: check() compares with the same command on the
|
|
7
|
+
// task's base commit (a throwaway worktree, cached per base and command), and runs a failing
|
|
8
|
+
// command twice so a flaky test doesn't send the agent off to fix nothing.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { git } from './git.js';
|
|
15
|
+
import { killTree } from './proc.js';
|
|
16
|
+
|
|
17
|
+
const MAX_OUTPUT = 400 * 1024;
|
|
18
|
+
export const CHECK_TIMEOUT_MS = Number(process.env.WISP_CHECK_TIMEOUT_MS || 10 * 60_000);
|
|
19
|
+
|
|
20
|
+
const read = (f) => {
|
|
21
|
+
try {
|
|
22
|
+
return fs.readFileSync(f, 'utf8');
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const has = (dir, f) => fs.existsSync(path.join(dir, f));
|
|
28
|
+
|
|
29
|
+
// { command, kind, why } | { skip: reason } | null (nothing that looks like tests).
|
|
30
|
+
// project.testCommand overrides it; 'off' turns the check off for that project.
|
|
31
|
+
export function detectTests(dir, project = null) {
|
|
32
|
+
const custom = String(project?.testCommand || '').trim();
|
|
33
|
+
if (custom === 'off') return { skip: 'Turned off for this project' };
|
|
34
|
+
if (custom) return { command: custom, kind: 'custom', why: 'Set for this project' };
|
|
35
|
+
|
|
36
|
+
const pkg = JSON.parse(read(path.join(dir, 'package.json')) || 'null');
|
|
37
|
+
const script = pkg?.scripts?.test;
|
|
38
|
+
if (script && !/no test specified/.test(script)) {
|
|
39
|
+
const deps = Object.keys({ ...pkg.dependencies, ...pkg.devDependencies });
|
|
40
|
+
if (deps.length && !has(dir, 'node_modules')) return { skip: 'Dependencies are not installed in this worktree (no node_modules)' };
|
|
41
|
+
const pm = has(dir, 'pnpm-lock.yaml') ? 'pnpm' : has(dir, 'yarn.lock') ? 'yarn' : has(dir, 'bun.lockb') || has(dir, 'bun.lock') ? 'bun' : 'npm';
|
|
42
|
+
return { command: `${pm} test`, kind: 'node', why: 'package.json "test" script' };
|
|
43
|
+
}
|
|
44
|
+
const py = ['pytest.ini', 'tox.ini', 'conftest.py'].some((f) => has(dir, f)) || /\[tool\.pytest/.test(read(path.join(dir, 'pyproject.toml')) || '') || /\[tool:pytest\]/.test(read(path.join(dir, 'setup.cfg')) || '') || (has(dir, 'tests') && fs.readdirSync(path.join(dir, 'tests')).some((f) => /^test_.*\.py$|_test\.py$/.test(f)));
|
|
45
|
+
if (py) {
|
|
46
|
+
const python = ['.venv/bin/python', 'venv/bin/python', '.venv/Scripts/python.exe'].find((p) => has(dir, p)) || 'python3';
|
|
47
|
+
return { command: `${python} -m pytest -q -rA`, kind: 'pytest', why: 'pytest configuration' };
|
|
48
|
+
}
|
|
49
|
+
if (has(dir, 'go.mod')) return { command: 'go test ./...', kind: 'go', why: 'go.mod' };
|
|
50
|
+
if (has(dir, 'Cargo.toml')) return { command: 'cargo test', kind: 'cargo', why: 'Cargo.toml' };
|
|
51
|
+
if (has(dir, 'Gemfile') && has(dir, 'spec')) return { command: 'bundle exec rspec', kind: 'rspec', why: 'Gemfile and spec/' };
|
|
52
|
+
if (/^test:/m.test(read(path.join(dir, 'Makefile')) || '')) return { command: 'make test', kind: 'make', why: 'Makefile test target' };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Failing test names and pass/fail counts from whatever reporter printed `out`.
|
|
57
|
+
export function parseResults(out) {
|
|
58
|
+
const failed = new Set();
|
|
59
|
+
let passed = null;
|
|
60
|
+
let failedN = null;
|
|
61
|
+
let skipped = null;
|
|
62
|
+
const add = (x) => x && failed.add(x.trim().replace(/\s+\(\d+(\.\d+)?m?s\)$/, ''));
|
|
63
|
+
for (const line of out.split('\n')) {
|
|
64
|
+
let m;
|
|
65
|
+
if ((m = line.match(/^\s*✖ (.+?)(?: \([\d.]+m?s\))?$/)) && !/^failing tests:$/.test(m[1])) add(m[1]); // node --test spec
|
|
66
|
+
else if ((m = line.match(/^not ok \d+ - (.+?)(?: # .*)?$/))) add(m[1]); // TAP, top level
|
|
67
|
+
else if ((m = line.match(/^(?:FAILED|ERROR) (\S+)/))) add(m[1]); // pytest -rA
|
|
68
|
+
else if ((m = line.match(/^\s+[✕×] (.+?)(?: \(\d+ ?ms\))?$/))) add(m[1]); // jest / vitest
|
|
69
|
+
else if ((m = line.match(/^--- FAIL: (\S+)/))) add(m[1]); // go
|
|
70
|
+
else if ((m = line.match(/^test (\S+) \.\.\. FAILED/))) add(m[1]); // cargo
|
|
71
|
+
else if ((m = line.match(/^rspec (\.\/\S+)/))) add(m[1]); // rspec
|
|
72
|
+
if ((m = line.match(/^ℹ pass (\d+)/))) passed = +m[1];
|
|
73
|
+
if ((m = line.match(/^ℹ fail (\d+)/))) failedN = +m[1];
|
|
74
|
+
if ((m = line.match(/^ℹ skipped (\d+)/))) skipped = +m[1];
|
|
75
|
+
if ((m = line.match(/^# pass (\d+)/))) passed = +m[1];
|
|
76
|
+
if ((m = line.match(/^# fail (\d+)/))) failedN = +m[1];
|
|
77
|
+
if ((m = line.match(/^=+ (.*) in [\d.]+s/))) {
|
|
78
|
+
// pytest summary: "== 3 failed, 41 passed, 2 skipped in 1.2s =="
|
|
79
|
+
passed = +(m[1].match(/(\d+) passed/)?.[1] || 0);
|
|
80
|
+
failedN = +(m[1].match(/(\d+) failed/)?.[1] || 0) + +(m[1].match(/(\d+) errors?/)?.[1] || 0);
|
|
81
|
+
skipped = +(m[1].match(/(\d+) skipped/)?.[1] || 0) || null;
|
|
82
|
+
}
|
|
83
|
+
if ((m = line.match(/^Tests:\s+(.*)$/))) {
|
|
84
|
+
// jest / vitest: "Tests: 1 failed, 12 passed, 13 total"
|
|
85
|
+
passed = +(m[1].match(/(\d+) passed/)?.[1] || 0);
|
|
86
|
+
failedN = +(m[1].match(/(\d+) failed/)?.[1] || 0);
|
|
87
|
+
skipped = +(m[1].match(/(\d+) skipped/)?.[1] || 0) || null;
|
|
88
|
+
}
|
|
89
|
+
if ((m = line.match(/^test result: \w+\. (\d+) passed; (\d+) failed/))) {
|
|
90
|
+
passed = (passed || 0) + +m[1];
|
|
91
|
+
failedN = (failedN || 0) + +m[2];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { failed: [...failed].slice(0, 200), passed, failedCount: failedN ?? failed.size, skipped };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Run `command` in dir: { exitCode, output, durationMs, timedOut, ...parseResults }.
|
|
98
|
+
export function runCheck(dir, command, { timeout = CHECK_TIMEOUT_MS, env = {} } = {}) {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const started = Date.now();
|
|
101
|
+
let output = '';
|
|
102
|
+
let timedOut = false;
|
|
103
|
+
// Not inherited: a test runner's own markers (wisp under `node --test` would make the project's
|
|
104
|
+
// `node --test` report to it and exit 0) and wisp's.
|
|
105
|
+
const base = Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^(NODE_TEST_CONTEXT|NODE_UNIQUE_ID|WISP_|FAKE_)/.test(k)));
|
|
106
|
+
const child = spawn(process.platform === 'win32' ? 'cmd.exe' : 'sh', process.platform === 'win32' ? ['/d', '/s', '/c', command] : ['-c', command], {
|
|
107
|
+
cwd: dir,
|
|
108
|
+
env: { ...base, CI: '1', FORCE_COLOR: '0', NO_COLOR: '1', ...env },
|
|
109
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
110
|
+
detached: process.platform !== 'win32',
|
|
111
|
+
});
|
|
112
|
+
const take = (d) => {
|
|
113
|
+
output += d;
|
|
114
|
+
if (output.length > MAX_OUTPUT * 2) output = output.slice(-MAX_OUTPUT);
|
|
115
|
+
};
|
|
116
|
+
child.stdout.on('data', take);
|
|
117
|
+
child.stderr.on('data', take);
|
|
118
|
+
const timer = setTimeout(() => {
|
|
119
|
+
timedOut = true;
|
|
120
|
+
killTree(child);
|
|
121
|
+
}, timeout);
|
|
122
|
+
const done = (exitCode) => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
output = output.replace(/\x1b\[[0-9;]*m/g, '').slice(-MAX_OUTPUT);
|
|
125
|
+
resolve({ exitCode, output, durationMs: Date.now() - started, timedOut, ...parseResults(output) });
|
|
126
|
+
};
|
|
127
|
+
child.on('error', (err) => ((output += `\n${err.message}`), done(127)));
|
|
128
|
+
child.on('close', (code) => done(timedOut ? 124 : code ?? 1));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The same command at the base commit, once per (repo, base, command).
|
|
133
|
+
const baselines = new Map();
|
|
134
|
+
export async function baseline(wt, command) {
|
|
135
|
+
if (!wt?.root || !wt.head) return null;
|
|
136
|
+
const key = `${wt.root}\0${wt.head}\0${command}`;
|
|
137
|
+
if (!baselines.has(key)) {
|
|
138
|
+
baselines.set(
|
|
139
|
+
key,
|
|
140
|
+
(async () => {
|
|
141
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wisp-base-'));
|
|
142
|
+
try {
|
|
143
|
+
await git(wt.root, ['worktree', 'add', '--quiet', '--detach', dir, wt.head]);
|
|
144
|
+
// node_modules and virtualenvs aren't in git: borrow the task's, so the base can run too.
|
|
145
|
+
for (const d of ['node_modules', '.venv', 'venv']) {
|
|
146
|
+
const from = path.join(wt.path, d);
|
|
147
|
+
if (fs.existsSync(from) && !fs.existsSync(path.join(dir, d))) fs.symlinkSync(from, path.join(dir, d), 'dir');
|
|
148
|
+
}
|
|
149
|
+
const sub = path.relative(wt.path, wt.cwd || wt.path);
|
|
150
|
+
return await runCheck(path.join(dir, sub), command);
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
} finally {
|
|
154
|
+
await git(wt.root, ['worktree', 'remove', '--force', dir]).catch(() => {});
|
|
155
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
})(),
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return baselines.get(key);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The verdict wisp acts on. pass: exit 0, or every failure was failing on the base already.
|
|
164
|
+
// fail: tests that passed on the base fail now (twice). unknown: it failed and we can't tell why
|
|
165
|
+
// (no base to compare with, or a reporter we can't read). skip: nothing to run.
|
|
166
|
+
export async function check({ dir, project, worktree, timeout } = {}) {
|
|
167
|
+
const found = detectTests(dir, project);
|
|
168
|
+
if (!found) return { verdict: 'skip', reason: 'No test command found', command: null };
|
|
169
|
+
if (found.skip) return { verdict: 'skip', reason: found.skip, command: null };
|
|
170
|
+
const { command } = found;
|
|
171
|
+
let run = await runCheck(dir, command, { timeout });
|
|
172
|
+
if (run.exitCode === 0 && !run.timedOut) return summary('pass', command, run, [], []);
|
|
173
|
+
// Flaky? Only failures that happen twice count.
|
|
174
|
+
const again = await runCheck(dir, command, { timeout });
|
|
175
|
+
if (again.exitCode === 0 && !again.timedOut) return summary('pass', command, again, [], [], 'Passed on a second run (a flaky test)');
|
|
176
|
+
const twice = run.failed.filter((t) => again.failed.includes(t));
|
|
177
|
+
run = { ...again, failed: twice.length || !again.failed.length ? twice : again.failed };
|
|
178
|
+
const base = await baseline(worktree, command);
|
|
179
|
+
if (!base) return summary(run.failed.length || run.timedOut ? 'fail' : 'unknown', command, run, run.failed, [], 'No base to compare with');
|
|
180
|
+
if (base.exitCode === 0) return summary('fail', command, run, run.failed, []);
|
|
181
|
+
const before = new Set(base.failed);
|
|
182
|
+
const fresh = run.failed.filter((t) => !before.has(t));
|
|
183
|
+
const old = run.failed.filter((t) => before.has(t));
|
|
184
|
+
if (!run.failed.length || !base.failed.length) return summary('unknown', command, run, [], [], 'Fails on the base commit too, and the failures could not be read');
|
|
185
|
+
return summary(fresh.length ? 'fail' : 'pass', command, run, fresh, old, fresh.length ? '' : `${old.length} failing on the base commit too`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function summary(verdict, command, run, fresh, old, note = '') {
|
|
189
|
+
const tail = run.output.split('\n').filter((l) => l.trim()).slice(-60).join('\n');
|
|
190
|
+
return {
|
|
191
|
+
verdict,
|
|
192
|
+
command,
|
|
193
|
+
exitCode: run.exitCode,
|
|
194
|
+
passed: run.passed,
|
|
195
|
+
failed: run.failedCount || run.failed.length || (run.exitCode ? 1 : 0),
|
|
196
|
+
skipped: run.skipped,
|
|
197
|
+
newFailures: fresh.slice(0, 50),
|
|
198
|
+
preexisting: old.slice(0, 50),
|
|
199
|
+
timedOut: run.timedOut,
|
|
200
|
+
durationMs: run.durationMs,
|
|
201
|
+
note,
|
|
202
|
+
output: verdict === 'pass' ? '' : tail.slice(-6000),
|
|
203
|
+
at: Date.now(),
|
|
204
|
+
};
|
|
205
|
+
}
|
package/server/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { Routes } from './http/router.js';
|
|
|
25
25
|
import { send, eventStream } from './http/respond.js';
|
|
26
26
|
import { serveStatic } from './http/static.js';
|
|
27
27
|
import { createAuto } from './services/auto.js';
|
|
28
|
+
import { scoutNote } from './scout.js';
|
|
28
29
|
import { createFeedback } from './services/feedback.js';
|
|
29
30
|
import { createInstaller } from './services/install.js';
|
|
30
31
|
import { createLaunch } from './services/launch.js';
|
|
@@ -119,7 +120,7 @@ export async function startServer({ port = DEFAULT_PORT, host = '127.0.0.1', han
|
|
|
119
120
|
app.prefs = new Preferences(store.home);
|
|
120
121
|
app.learning = () => store.config.defaults.learn !== 'off';
|
|
121
122
|
const projectOf = (cwd) => store.projectFor(cwd)?.path || cwd;
|
|
122
|
-
runner.promptExtra = (s) => (app.learning() && s.agent !== 'wisp' ? app.prefs.prompt(projectOf(s.cwd)) : '');
|
|
123
|
+
runner.promptExtra = (s) => (app.learning() && s.agent !== 'wisp' ? app.prefs.prompt(projectOf(s.cwd)) : '') + scoutNote(s.scout);
|
|
123
124
|
app.retro = new Retro({
|
|
124
125
|
store,
|
|
125
126
|
runner,
|
package/server/judge.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Picking between two runs of the same task (best-of-2). Tests decide when they can: a run whose
|
|
2
|
+
// check passed beats one whose check failed. Otherwise a cheap model reads both diffs, and if
|
|
3
|
+
// that isn't available either, fewer new failures and then the smaller diff win.
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { askJson } from './quick.js';
|
|
10
|
+
|
|
11
|
+
const MAX_DIFF = 24_000;
|
|
12
|
+
|
|
13
|
+
// The run's changes against its base: tracked diff plus new files.
|
|
14
|
+
export function diffOf(dir, base = 'HEAD') {
|
|
15
|
+
const run = (args) => {
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync('git', args, { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20, timeout: 15_000 }).toString();
|
|
18
|
+
} catch {
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
let out = run(['diff', base, '--', '.', ':(exclude)node_modules']);
|
|
23
|
+
for (const f of run(['ls-files', '--others', '--exclude-standard']).split('\n').filter(Boolean).slice(0, 30)) {
|
|
24
|
+
try {
|
|
25
|
+
out += `\n--- /dev/null\n+++ b/${f}\n${fs.readFileSync(path.join(dir, f), 'utf8').slice(0, 4000).split('\n').map((l) => `+${l}`).join('\n')}`;
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const changed = (d) => d.split('\n').filter((l) => /^[+-](?![+-])/.test(l)).length;
|
|
32
|
+
const SCORE = { pass: 3, unknown: 2, skip: 2, fail: 0 };
|
|
33
|
+
|
|
34
|
+
// candidates: [{ id, label, check, diff }]. Resolves with { winner, why, by }.
|
|
35
|
+
export async function judge({ prompt, candidates, agents }) {
|
|
36
|
+
const [a, b] = candidates;
|
|
37
|
+
const sa = SCORE[a.check?.verdict] ?? 1;
|
|
38
|
+
const sb = SCORE[b.check?.verdict] ?? 1;
|
|
39
|
+
if (sa !== sb) {
|
|
40
|
+
const w = sa > sb ? a : b;
|
|
41
|
+
return { winner: w.id, by: 'tests', why: `${w.label}'s changes pass the tests; the other run's don't.` };
|
|
42
|
+
}
|
|
43
|
+
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'wisp-judge-'));
|
|
44
|
+
try {
|
|
45
|
+
const ask = `Two coding agents did the same task. Judge which change is better: correct, complete, minimal, and in the style of the code around it. Tests: run A ${a.check?.verdict || 'not checked'}, run B ${b.check?.verdict || 'not checked'}.
|
|
46
|
+
|
|
47
|
+
<task>
|
|
48
|
+
${String(prompt).slice(0, 4000)}
|
|
49
|
+
</task>
|
|
50
|
+
|
|
51
|
+
<run id="A" label="${a.label}">
|
|
52
|
+
${a.diff.slice(0, MAX_DIFF) || '(no changes)'}
|
|
53
|
+
</run>
|
|
54
|
+
|
|
55
|
+
<run id="B" label="${b.label}">
|
|
56
|
+
${b.diff.slice(0, MAX_DIFF) || '(no changes)'}
|
|
57
|
+
</run>
|
|
58
|
+
|
|
59
|
+
Reply with one JSON object: {"winner": "A" | "B", "why": "one sentence"}`;
|
|
60
|
+
const j = await askJson({ agents, prompt: ask, cwd, timeoutMs: 120_000 });
|
|
61
|
+
if (j && (j.winner === 'A' || j.winner === 'B')) return { winner: (j.winner === 'A' ? a : b).id, by: 'model', why: String(j.why || '').slice(0, 300) };
|
|
62
|
+
} finally {
|
|
63
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
const fa = a.check?.newFailures?.length ?? 0;
|
|
66
|
+
const fb = b.check?.newFailures?.length ?? 0;
|
|
67
|
+
if (fa !== fb) {
|
|
68
|
+
const w = fa < fb ? a : b;
|
|
69
|
+
return { winner: w.id, by: 'rule', why: `${w.label} leaves fewer new failing tests.` };
|
|
70
|
+
}
|
|
71
|
+
const empty = (c) => !c.diff.trim();
|
|
72
|
+
if (empty(a) !== empty(b)) {
|
|
73
|
+
const w = empty(a) ? b : a;
|
|
74
|
+
return { winner: w.id, by: 'rule', why: `${w.label} changed something; the other run didn't.` };
|
|
75
|
+
}
|
|
76
|
+
const w = changed(a.diff) <= changed(b.diff) ? a : b;
|
|
77
|
+
return { winner: w.id, by: 'rule', why: `${w.label} made the smaller change.` };
|
|
78
|
+
}
|
package/server/openapi.js
CHANGED
|
@@ -47,7 +47,7 @@ export function openapi({ version, port }) {
|
|
|
47
47
|
'/agents': { get: { summary: 'Installed harnesses with their models and thinking levels (?fresh=1 detects again)', responses: { 200: json({ type: 'array', items: ref('Agent') }) } } },
|
|
48
48
|
'/router': {
|
|
49
49
|
get: { summary: 'Auto: the goal, settings, every harness · model with its learned chance of success and cost, and recent routes', responses: { 200: json({ type: 'object' }) } },
|
|
50
|
-
patch: { summary: 'Auto settings', requestBody: body({ type: 'object', properties: { goal: { enum: ['cheap', 'balanced', 'best'] }, explore: { type: 'boolean', description: 'Let Auto try new models on a few tasks' }, allowed: { type: 'array', items: { type: 'string' }, description: 'Harness ids Auto may pick; empty or null = all' } } }), responses: { 200: json({ type: 'object' }) } },
|
|
50
|
+
patch: { summary: 'Auto settings', requestBody: body({ type: 'object', properties: { goal: { enum: ['cheap', 'balanced', 'best'] }, explore: { type: 'boolean', description: 'Let Auto try new models on a few tasks' }, allowed: { type: 'array', items: { type: 'string' }, description: 'Harness ids Auto may pick; empty or null = all' }, check: { type: 'boolean', description: 'Run the project tests after an Auto run; only new failures count' }, repairs: { type: 'integer', minimum: 0, maximum: 2, description: 'Times failing tests go back to the same agent' }, escalate: { type: 'boolean', description: 'Still failing: run the task again on a stronger pick, in its own worktree' }, best2: { type: 'boolean', description: 'Goal Best: two picks side by side, the better one marked' }, scout: { enum: ['off', 'big', 'all'], description: 'A cheap model looks at the repo first' } } }), responses: { 200: json({ type: 'object' }) } },
|
|
51
51
|
},
|
|
52
52
|
'/router/reset': { post: { summary: 'Forget what Auto learned here (the shipped starting point stays)', responses: { 200: json({ type: 'object' }) } } },
|
|
53
53
|
'/agents/install': {
|
|
@@ -144,7 +144,7 @@ export function openapi({ version, port }) {
|
|
|
144
144
|
},
|
|
145
145
|
},
|
|
146
146
|
'/sessions/{id}/pr': { parameters: [id], post: { summary: 'Refresh the PR status (checks, review, state) now; it is also polled every minute', responses: { 200: json(ref('PullRequest')), 404: err[404] } } },
|
|
147
|
-
'/sessions/{id}/fix-conflicts': { parameters: [id], post: { summary: '
|
|
147
|
+
'/sessions/{id}/fix-conflicts': { parameters: [id], post: { summary: 'Fix the PR\'s merge conflicts now (the PR row\'s Fix conflicts button): a follow-up merges the base in, then wisp pushes to the PR', responses: { 200: json({ type: 'object' }), 400: err[400] } } },
|
|
148
148
|
'/sessions/{id}/merge': {
|
|
149
149
|
parameters: [id],
|
|
150
150
|
post: { summary: 'Mark the PR ready if draft, squash-merge it with --match-head-commit, fast-forward the local base branch, remove the worktree', responses: { 200: json({ type: 'object', properties: { pr: ref('PullRequest'), sync: { type: 'object', properties: { ok: { type: 'boolean' }, text: { type: 'string' } } } } }), 400: err[400] } },
|
|
@@ -409,7 +409,7 @@ export function openapi({ version, port }) {
|
|
|
409
409
|
branch: { type: 'string', description: 'Worktree branch, if it has one' },
|
|
410
410
|
pr: { type: ['object', 'null'], description: 'Its pull request, once shipped' },
|
|
411
411
|
shipping: { type: ['object', 'null'], description: "{ step: start | commit | push | pr, update } while Ship it runs" },
|
|
412
|
-
conflictFix: { type: ['object', 'null'], description: "{ step: fixing | pushing | done | failed, why } while wisp fixes the PR's merge conflicts on its own
|
|
412
|
+
conflictFix: { type: ['object', 'null'], description: "{ step: fixing | pushing | done | failed, why } while wisp fixes the PR's merge conflicts: after Fix conflicts, or on its own with defaults.fixConflicts: 'on'" },
|
|
413
413
|
groupId: { type: ['string', 'null'], description: 'Shared by sessions launched together with compare' },
|
|
414
414
|
origin: { type: ['object', 'null'], description: '{ kind: recipe | trigger, id, name } when not launched by hand' },
|
|
415
415
|
},
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// What Auto does after the agent says it's done, before the task lands in your queue:
|
|
2
|
+
//
|
|
3
|
+
// check wisp runs the project's tests itself (server/check.js). Only new failures count.
|
|
4
|
+
// repair tests fail → the same session gets the failures back, up to `repairs` rounds.
|
|
5
|
+
// escalate still failing (or the run errored) → the task goes to a stronger harness · model,
|
|
6
|
+
// in its own worktree, next to the first run (one task, two rows).
|
|
7
|
+
// judge best-of-2 (goal Best): both runs are checked, then the better one is marked
|
|
8
|
+
// (server/judge.js): tests first, then a cheap model reading both diffs.
|
|
9
|
+
//
|
|
10
|
+
// The turn's presentation gets wisp's own tests block, and when the agent said "all green" but
|
|
11
|
+
// wisp's run disagrees, a callout saying so. The router learns from the verdicts.
|
|
12
|
+
//
|
|
13
|
+
// State lives on the session as `auto`: { strategy, busy, stage, label, repairs, checks[],
|
|
14
|
+
// escalatedTo, escalatedFrom, pick }. The runner keeps a session in Working while `busy`.
|
|
15
|
+
|
|
16
|
+
import { check as runCheck } from './check.js';
|
|
17
|
+
import { judge, diffOf } from './judge.js';
|
|
18
|
+
import { workdir } from './runner.js';
|
|
19
|
+
import { infraError, tierOf } from './router.js';
|
|
20
|
+
|
|
21
|
+
// What an errored turn said (the terminal block the runner puts on it).
|
|
22
|
+
const errorText = (turn) => (turn.presentation?.blocks || []).filter((b) => b.type === 'terminal').map((b) => b.output || '').join('\n');
|
|
23
|
+
const MAX_MOVES = 4; // a task moved this many times because harnesses were at their limit stops there
|
|
24
|
+
|
|
25
|
+
export const AUTO_DEFAULTS = { check: 'on', repairs: 1, escalate: 'on', best2: 'off', scout: 'big' };
|
|
26
|
+
|
|
27
|
+
export function autoSettings(d = {}) {
|
|
28
|
+
const r = d.router || {};
|
|
29
|
+
return {
|
|
30
|
+
check: r.check !== 'off',
|
|
31
|
+
repairs: [0, 1, 2].includes(Number(r.repairs)) ? Number(r.repairs) : AUTO_DEFAULTS.repairs,
|
|
32
|
+
escalate: r.escalate !== 'off',
|
|
33
|
+
best2: r.best2 === 'on', // opt-in: it runs every Best task twice
|
|
34
|
+
scout: ['off', 'big', 'all'].includes(r.scout) ? r.scout : AUTO_DEFAULTS.scout,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
39
|
+
|
|
40
|
+
export class Pipeline {
|
|
41
|
+
// deps: { store, runner, router, settings(), agents(), projectOf(session), launchSibling(session, pick),
|
|
42
|
+
// onSettled(session), broadcast(event, data) }
|
|
43
|
+
constructor(deps) {
|
|
44
|
+
Object.assign(this, deps);
|
|
45
|
+
this.tokens = new Map(); // sessionId -> token of the step in flight; a user's move cancels it
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Called by the runner as a turn ends: true keeps the session in Working while wisp checks.
|
|
49
|
+
wants(session, turn, ev) {
|
|
50
|
+
const a = session.auto;
|
|
51
|
+
if (!a || session.parentId || ev.stopped || session.mode === 'plan') return false;
|
|
52
|
+
const s = this.settings();
|
|
53
|
+
// At its limit: not the model's fault, so the task moves on whatever the hand-over setting says.
|
|
54
|
+
if (ev.isError) return !!infraError(ev.text) || (s.escalate && this.#canEscalate(session));
|
|
55
|
+
return s.check;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A follow-up or Stop from the user: whatever wisp was about to do is off.
|
|
59
|
+
cancel(session) {
|
|
60
|
+
if (!session.auto?.busy) return;
|
|
61
|
+
this.tokens.delete(session.id);
|
|
62
|
+
Object.assign(session.auto, { busy: false, stage: 'cancelled', label: '' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async run(session, turn) {
|
|
66
|
+
const a = session.auto;
|
|
67
|
+
const token = Symbol('step');
|
|
68
|
+
this.tokens.set(session.id, token);
|
|
69
|
+
const live = () => this.tokens.get(session.id) === token && this.store.get(session.id) === session;
|
|
70
|
+
const s = this.settings();
|
|
71
|
+
try {
|
|
72
|
+
let result;
|
|
73
|
+
const limit = turn.status === 'error' && infraError(errorText(turn));
|
|
74
|
+
if (limit) return await this.#moveOn(session, turn, limit, live);
|
|
75
|
+
if (turn.status === 'error') result = { verdict: 'error' };
|
|
76
|
+
else {
|
|
77
|
+
this.#stage(session, turn, 'checking', 'Checking with the tests');
|
|
78
|
+
result = await runCheck({ dir: workdir(session), project: this.projectOf(session), worktree: session.worktree });
|
|
79
|
+
if (!live()) return;
|
|
80
|
+
a.checks.push({ turnId: turn.id, ...result, output: undefined });
|
|
81
|
+
this.#present(session, turn, result);
|
|
82
|
+
}
|
|
83
|
+
this.router.signal(session.id, { check: result.verdict, repaired: a.repairs > 0 || undefined });
|
|
84
|
+
|
|
85
|
+
const failing = result.verdict === 'fail' || result.verdict === 'error';
|
|
86
|
+
// Repairs and hand-overs are for the task as you sent it: once you've followed up yourself,
|
|
87
|
+
// wisp still checks and shows the result, but leaves the next move to you.
|
|
88
|
+
const yours = session.turns.some((t, i) => i > 0 && t.by !== 'wisp');
|
|
89
|
+
if (result.verdict === 'fail' && a.repairs < s.repairs && !yours) {
|
|
90
|
+
a.repairs++;
|
|
91
|
+
this.#stage(session, turn, 'repairing', `Fixing ${plural(result.newFailures.length || result.failed || 1, 'failing test')}`);
|
|
92
|
+
this.runner.reply(session, repairPrompt(result, a.repairs, s.repairs), [], { by: 'wisp' });
|
|
93
|
+
return; // the repair turn comes back through wants()/run()
|
|
94
|
+
}
|
|
95
|
+
if (failing && s.escalate && this.#canEscalate(session)) {
|
|
96
|
+
// A step up: a stronger tier than this run's (or the strongest there is, if it was already top).
|
|
97
|
+
const tier = tierOf(session.route?.key || '');
|
|
98
|
+
const pick = this.router.choose({ prompt: session.turns[0].prompt, agents: this.agents(), goal: 'best', important: true, minTier: Math.min(3, tier + 1), exclude: [session.route?.key, ...(a.tried || [])].filter(Boolean), needDelegate: !!session.subagents?.length });
|
|
99
|
+
if (!pick || tierOf(pick.key) < tier) this.#note(session, turn, 'check', 'No stronger harness · model to hand it to');
|
|
100
|
+
else {
|
|
101
|
+
this.#stage(session, turn, 'escalating', `Handing it to ${pick.label}`);
|
|
102
|
+
const sib = await this.launchSibling(session, pick, { reason: result.verdict === 'error' ? 'error' : 'tests' });
|
|
103
|
+
if (!live()) return;
|
|
104
|
+
a.escalatedTo = sib.id;
|
|
105
|
+
a.reason = result.verdict === 'error' ? 'error' : 'tests';
|
|
106
|
+
this.#note(session, turn, 'check', `wisp handed this task to ${pick.label}: ${result.verdict === 'error' ? 'the run ended with an error' : 'the tests still fail'}`);
|
|
107
|
+
return this.#settle(session, { quiet: true });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
this.#settle(session);
|
|
111
|
+
if (a.strategy === 'best2') await this.#judgeGroup(session);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.warn(`wisp: auto pipeline: ${err.stack || err.message}`);
|
|
114
|
+
if (live()) this.#settle(session);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The harness was at a usage or rate limit: the run doesn't count against it, it cools down until
|
|
119
|
+
// the limit resets, and the task moves to the best pick that can run now.
|
|
120
|
+
async #moveOn(session, turn, limit, live) {
|
|
121
|
+
const a = session.auto;
|
|
122
|
+
const why = errorText(turn).split('\n').find((l) => l.trim())?.trim() || 'at its limit';
|
|
123
|
+
const cooled = this.router.cool(session.route?.key || `${session.agent}:${session.model || ''}`, { ...limit, why });
|
|
124
|
+
this.router.void(session.id);
|
|
125
|
+
const name = cooled.endsWith(':*') ? session.route?.label?.split(' · ')[0] || session.agent : session.route?.label || session.agent;
|
|
126
|
+
const tried = [...(a.tried || []), session.route?.key].filter(Boolean);
|
|
127
|
+
const pick = tried.length < MAX_MOVES ? this.router.choose({ prompt: session.turns[0].prompt, agents: this.agents(), goal: session.route?.goal || 'balanced', exclude: tried, needDelegate: !!session.subagents?.length }) : null;
|
|
128
|
+
if (!pick) {
|
|
129
|
+
this.#note(session, turn, 'check', `${name} is at its limit, and nothing else Auto may use can run right now`);
|
|
130
|
+
return this.#settle(session);
|
|
131
|
+
}
|
|
132
|
+
this.#stage(session, turn, 'moving', `${name} is at its limit · moving to ${pick.label}`);
|
|
133
|
+
const sib = await this.launchSibling(session, pick, { reason: 'limit', tried });
|
|
134
|
+
if (!live()) return;
|
|
135
|
+
a.escalatedTo = sib.id;
|
|
136
|
+
a.reason = 'limit';
|
|
137
|
+
return this.#settle(session, { quiet: true });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Escalate once per task, and only while the user hasn't stepped in with follow-ups.
|
|
141
|
+
#canEscalate(session) {
|
|
142
|
+
const a = session.auto;
|
|
143
|
+
// A fresh worktree from the same base, or the second run would build on the first one's mess.
|
|
144
|
+
return a.strategy === 'single' && !a.escalatedTo && !!session.worktree && !session.turns.some((t, i) => i > 0 && t.by !== 'wisp');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#stage(session, turn, stage, label) {
|
|
148
|
+
Object.assign(session.auto, { busy: true, stage, label });
|
|
149
|
+
this.#note(session, turn, 'check', `${label}…`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#note(session, turn, kind, label) {
|
|
153
|
+
this.runner.note(session, turn, { kind, label });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
#settle(session, { quiet = false } = {}) {
|
|
157
|
+
this.tokens.delete(session.id);
|
|
158
|
+
Object.assign(session.auto, { busy: false, stage: 'done', label: '' });
|
|
159
|
+
this.runner.settle(session, { quiet });
|
|
160
|
+
this.onSettled?.(session);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// wisp's tests block goes right under the hero; a "done" that wisp's run contradicts is called out.
|
|
164
|
+
#present(session, turn, r) {
|
|
165
|
+
const blocks = turn.presentation?.blocks;
|
|
166
|
+
if (!blocks) return;
|
|
167
|
+
const label = `${r.command ? `\`${r.command}\`` : 'the tests'}`;
|
|
168
|
+
if (r.verdict === 'skip') {
|
|
169
|
+
this.#note(session, turn, 'check', `No tests to run: ${r.reason}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const failures = (r.newFailures.length ? r.newFailures : r.verdict === 'fail' ? ['(see output)'] : []).slice(0, 8).map((name) => ({ name, message: '' }));
|
|
173
|
+
const block = { type: 'tests', by: 'wisp', command: r.command, passed: r.passed ?? undefined, failed: r.verdict === 'pass' ? 0 : r.newFailures.length || r.failed, skipped: r.skipped ?? undefined, durationMs: r.durationMs, failures, note: r.note || undefined };
|
|
174
|
+
if (r.verdict === 'pass' && block.passed == null) block.passed = 1;
|
|
175
|
+
const at = Math.max(0, blocks.findIndex((b) => b.type === 'hero') + 1);
|
|
176
|
+
blocks.splice(at, 0, block);
|
|
177
|
+
const hero = blocks.find((b) => b.type === 'hero');
|
|
178
|
+
const claimed = blocks.some((b) => b.type === 'tests' && b.by !== 'wisp' && !(Number(b.failed) > 0)) || hero?.status === 'done';
|
|
179
|
+
if (r.verdict === 'fail') {
|
|
180
|
+
if (hero && hero.status === 'done') (hero.agentStatus = hero.status), (hero.status = 'partial');
|
|
181
|
+
if (claimed) blocks.splice(at + 1, 0, { type: 'callout', tone: 'danger', by: 'wisp', title: "wisp's test run disagrees", text: `The agent reported this as done, but ${label} has ${plural(r.newFailures.length || r.failed, 'failing test')} that passed before the change.` });
|
|
182
|
+
}
|
|
183
|
+
this.#note(session, turn, 'check', r.verdict === 'pass' ? `Tests pass: ${label}${r.note ? ` (${r.note})` : ''}` : r.verdict === 'fail' ? `Tests fail: ${plural(r.newFailures.length || r.failed, 'new failure')} in ${label}` : `Tests inconclusive: ${r.note || label}`);
|
|
184
|
+
this.store.save(session);
|
|
185
|
+
this.broadcast('turn', { sessionId: session.id, turnId: turn.id });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Best-of-2: once both runs have settled, mark the better one.
|
|
189
|
+
async #judgeGroup(session) {
|
|
190
|
+
const group = this.store.all().filter((x) => x.groupId === session.groupId && x.auto?.strategy === 'best2');
|
|
191
|
+
if (group.length < 2 || group.some((x) => x.auto.busy || x.state === 'running') || group.some((x) => x.auto.pick)) return;
|
|
192
|
+
group.forEach((x) => (x.auto.pick = { pending: true }));
|
|
193
|
+
const candidates = group.slice(0, 2).map((x) => ({ id: x.id, label: x.route?.label || x.agent, check: x.auto.checks.at(-1), diff: diffOf(workdir(x), x.worktree?.head || 'HEAD') }));
|
|
194
|
+
const j = await judge({ prompt: group[0].turns[0].prompt, candidates, agents: this.agents() });
|
|
195
|
+
for (const x of group) {
|
|
196
|
+
x.auto.pick = { winner: x.id === j.winner, why: j.why, by: j.by };
|
|
197
|
+
this.store.save(x);
|
|
198
|
+
this.broadcast('summary', this.summarize(x));
|
|
199
|
+
}
|
|
200
|
+
const w = group.find((x) => x.id === j.winner);
|
|
201
|
+
if (w) this.#note(w, w.turns.at(-1), 'check', `wisp's pick of the two runs: ${j.why}`);
|
|
202
|
+
for (const x of group) this.router.signal(x.id, { picked: x.id === j.winner });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function repairPrompt(r, round, max) {
|
|
207
|
+
const list = r.newFailures.length ? r.newFailures.slice(0, 20).map((t) => `- ${t}`).join('\n') : '';
|
|
208
|
+
return [
|
|
209
|
+
`wisp ran \`${r.command}\` on your change and it fails${list ? ':' : '.'}`,
|
|
210
|
+
list,
|
|
211
|
+
r.output ? `Output (tail):\n\`\`\`\n${r.output.slice(-3500)}\n\`\`\`` : '',
|
|
212
|
+
'These tests passed before your change. Fix the code so they pass again: do not delete, skip or weaken tests. Run the tests yourself, then finish with an updated presentation.',
|
|
213
|
+
max > 1 ? `(Repair round ${round} of ${max}.)` : '',
|
|
214
|
+
]
|
|
215
|
+
.filter(Boolean)
|
|
216
|
+
.join('\n\n');
|
|
217
|
+
}
|