dsh-vibe-math 2.7.3 → 2.8.1
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/LICENSE +21 -21
- package/README.en.md +682 -682
- package/README.md +683 -683
- package/docs/AUDIT-CHECKLIST.md +758 -744
- package/docs/COMPAT-AUDIT-ROUND2.md +351 -351
- package/docs/COMPAT-AUDIT-ROUND3.md +154 -154
- package/docs/audit-methodology.md +93 -0
- package/docs/final-paper.md +172 -172
- package/docs/formal-verification.md +613 -588
- package/docs/generate_framework_diagram_v2.py +114 -114
- package/docs/generate_framework_diagram_v3.py +127 -127
- package/docs/math-computation.md +195 -194
- package/docs/release-notes/README.en.md +2 -0
- package/docs/release-notes/README.md +2 -0
- package/docs/release-notes/RELEASE-NOTES-2.0.22.md +112 -112
- package/docs/release-notes/RELEASE-NOTES-2.1.0.md +143 -143
- package/docs/release-notes/RELEASE-NOTES-2.2.0.md +266 -266
- package/docs/release-notes/RELEASE-NOTES-2.2.1.md +43 -43
- package/docs/release-notes/RELEASE-NOTES-2.2.2.md +88 -88
- package/docs/release-notes/RELEASE-NOTES-2.3.0.md +207 -207
- package/docs/release-notes/RELEASE-NOTES-2.3.1.md +134 -134
- package/docs/release-notes/RELEASE-NOTES-2.3.10.md +105 -105
- package/docs/release-notes/RELEASE-NOTES-2.3.11.md +57 -57
- package/docs/release-notes/RELEASE-NOTES-2.3.12.md +80 -80
- package/docs/release-notes/RELEASE-NOTES-2.3.2.md +145 -145
- package/docs/release-notes/RELEASE-NOTES-2.3.3.md +115 -115
- package/docs/release-notes/RELEASE-NOTES-2.3.4.md +69 -69
- package/docs/release-notes/RELEASE-NOTES-2.3.5.md +63 -63
- package/docs/release-notes/RELEASE-NOTES-2.3.6.md +66 -66
- package/docs/release-notes/RELEASE-NOTES-2.3.7.md +59 -59
- package/docs/release-notes/RELEASE-NOTES-2.3.8.md +45 -45
- package/docs/release-notes/RELEASE-NOTES-2.3.9.md +70 -70
- package/docs/release-notes/RELEASE-NOTES-2.8.0.en.md +65 -0
- package/docs/release-notes/RELEASE-NOTES-2.8.0.md +65 -0
- package/docs/release-notes/RELEASE-NOTES-2.8.1.en.md +49 -0
- package/docs/release-notes/RELEASE-NOTES-2.8.1.md +49 -0
- package/docs/release-playbook.md +111 -0
- package/docs/slv-playbook.md +117 -0
- package/docs/test-timing.md +158 -157
- package/docs//346/236/266/346/236/204/345/233/276.md +77 -77
- package/installer.js +792 -792
- package/package.json +18 -3
- package/prompt-corpus-v2/formal-verify-v2.json +2 -2
- package/prompt-corpus-v2/formal-verify-v2.md +4 -0
- package/prompt-corpus-v3/formal-verify-v3.json +7 -7
- package/prompt-corpus-v3/formal-verify-v3.md +14 -0
- package/prompt-corpus-v5/prompt-corpus-v5.json +61 -61
- package/prompt-corpus-v5/prompt-corpus-v5.md +122 -0
- package/scripts/clean-temp.mjs +234 -0
- package/scripts/release-check.mjs +181 -0
- package/tests/audit-engine-faces.mjs +298 -0
- package/tests/audit-formal-sensitivity.mjs +345 -342
- package/tests/audit-installer-compat.test.mjs +256 -256
- package/tests/audit-math-computation-sensitivity.mjs +1 -1
- package/tests/audit-persona-sensitivity.mjs +323 -323
- package/tests/audit-persona-surface.test.mjs +431 -431
- package/tests/audit-prompt-duplication.mutants.mjs +3 -2
- package/tests/audit-prompt-invariants.mjs +589 -546
- package/tests/audit-registration.mjs +78 -78
- package/tests/audit-spec-traceability.mjs +193 -193
- package/tests/audit-status-report-fields.mjs +172 -172
- package/tests/audit-v5-integrity.mjs +795 -795
- package/tests/audit-v5-sensitivity.mjs +433 -432
- package/tests/e2e-v5-round2.test.mjs +2453 -2426
- package/tests/formal-verify-v2.test.mjs +1806 -1800
- package/tests/formal-verify-v3.test.mjs +1757 -1750
- package/tests/formal-verify-v4.test.mjs +2114 -2106
- package/tests/formal-verify-v5.test.mjs +774 -767
- package/tests/math-computation-discovery.mutants.mjs +3 -3
- package/tests/math-computation-shared.test.mjs +122 -5
- package/tests/math-computation-v2.test.mjs +14 -2
- package/tests/math-computation-v3.test.mjs +14 -2
- package/tests/math-computation-v4.test.mjs +739 -739
- package/tests/prompt-v5-integrity.test.mjs +1291 -1291
- package/tests/release-check.mutants.mjs +78 -0
- package/tests/run-tests.mjs +393 -332
- package/tests/selfdrive-v5.mjs +560 -560
- package/tests/temp-hygiene.mutants.mjs +80 -0
- package/tests/v2-fix-probes.mutants.mjs +4 -0
- package/tests/v2-fix-probes.test.mjs +1034 -1033
- package/tests/v2v3-interrupt.mutants.mjs +111 -0
- package/tests/v3-fix-probes.mutants.mjs +99 -95
- package/tests/v3-fix-probes.test.mjs +891 -890
- package/tests/v4-final-paper.mutants.mjs +116 -0
- package/tests/v4-final-paper.test.mjs +628 -0
- package/tests/v5-institute-fixes.mutants.mjs +218 -190
- package/vibe-math-v2/agent.cordis.yml +376 -376
- package/vibe-math-v2/math-computation.js +17 -15
- package/vibe-math-v2/preset.yml +2 -2
- package/vibe-math-v2/v2/347/232/204/351/242/235/345/244/226/350/246/201/346/261/202.txt +21 -21
- package/vibe-math-v2/vibe-math-v2.js +5078 -5011
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +486 -486
- package/vibe-math-v3/agent.cordis.yml +420 -420
- package/vibe-math-v3/math-computation.js +17 -15
- package/vibe-math-v3/preset.yml +2 -2
- package/vibe-math-v3/vibe-math-v3.js +6215 -6149
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +649 -649
- package/vibe-math-v4/agent.cordis.yml +438 -438
- package/vibe-math-v4/math-computation.js +17 -15
- package/vibe-math-v4/preset.yml +2 -2
- package/vibe-math-v4/vibe-math-v4.js +4520 -4422
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +1015 -1015
- package/vibe-math-v5/math-computation.js +17 -15
- package/vibe-math-v5/vibe-math-v5.js +93 -11
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +1728 -1728
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +693 -693
|
@@ -1,739 +1,739 @@
|
|
|
1
|
-
// ============================================================
|
|
2
|
-
// math_computation — v4 WIRING suite (P1)
|
|
3
|
-
//
|
|
4
|
-
// Scope: this preset's接线 only — parameter surface, the ONE registration, prompt injection and the
|
|
5
|
-
// host adapter (`spawn`/`resolveExecutable`/fs) driving the SHARED module. The module's own behaviour
|
|
6
|
-
// is covered by the integration owner's `tests/math-computation-shared.test.mjs`; here every engine is
|
|
7
|
-
// a FAKE driven through the plugin's injected `subprocess` seam, so nothing real is executed.
|
|
8
|
-
//
|
|
9
|
-
// Fake-engine seam (guards.md §3): `resolveExecutable` resolves the P1 candidate names to shim paths;
|
|
10
|
-
// `spawn` classifies each argv against `math-engines.js` (version / licence / package probe / run) and
|
|
11
|
-
// records the full argv, cwd and stdio caps, plus every `terminate()` call.
|
|
12
|
-
//
|
|
13
|
-
// Run: node tests/math-computation-v4.test.mjs
|
|
14
|
-
// V4_PLUGIN=<path> points the suite at a MUTATED copy (used by the sensitivity probe).
|
|
15
|
-
//
|
|
16
|
-
// Mutants this suite must kill (each makes ≥1 assertion red):
|
|
17
|
-
// M1 `default-engines-dropped` — drop 'cli' from `MATH_ENGINE_ORDER` in `vibe-math-v4/math-engines.js`
|
|
18
|
-
// (the real source of the default engine list: v4's `DEFAULT_PARAMS.mathEngines` is only
|
|
19
|
-
// `MATH_PARAM_DEFAULTS.mathEngines.slice()`; the old text also named `defaultOn`, which does not
|
|
20
|
-
// exist anywhere in the tree — a phantom anchor, corrected per the anchor-hygiene rule): the
|
|
21
|
-
// "cli is ON by default" assertions (§8) go red. Measured with the corrected site: 2 named reds.
|
|
22
|
-
// M2 `typed-still-shell` — call mathAvailabilityLine without the mode (or force 'typed+shell'): §12
|
|
23
|
-
// finds the shell-fallback sentence in the typed prompt → red.
|
|
24
|
-
// M3 `timeout-no-kill` — return the timeout without calling terminate(): §5 asserts the terminate
|
|
25
|
-
// count, so dropping the kill is red (not merely a reported MATH_TIMEOUT).
|
|
26
|
-
// M4 `registration-dropped` — remove registerMathComputation (or register a different name): §1
|
|
27
|
-
// finds no `math_computation` tool and the description/schema parity checks fail.
|
|
28
|
-
// M5 `params-not-normalized` — normalizeParam returns the raw value for the six keys: §11 sees
|
|
29
|
-
// `mathMode=bogus` / `mathTimeoutMs=5` / `mathEngines='python,cli'` survive verbatim → red.
|
|
30
|
-
// ============================================================
|
|
31
|
-
import { mkdtempSync, existsSync, readFileSync, mkdirSync, writeFileSync, rmSync, statSync } from 'node:fs'
|
|
32
|
-
import { tmpdir } from 'node:os'
|
|
33
|
-
import { join, dirname, isAbsolute, basename, resolve as pathResolve } from 'node:path'
|
|
34
|
-
import { pathToFileURL, fileURLToPath } from 'node:url'
|
|
35
|
-
|
|
36
|
-
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
37
|
-
// V4_PERSONA_YML points the persona assertions at a MUTATED persona file (sensitivity probe).
|
|
38
|
-
const PERSONA_YML = process.env.V4_PERSONA_YML ? pathResolve(process.env.V4_PERSONA_YML) : join(HERE, '..', 'vibe-math-v4', 'agent.cordis.yml')
|
|
39
|
-
const PLUGIN = process.env.V4_PLUGIN
|
|
40
|
-
? pathToFileURL(isAbsolute(process.env.V4_PLUGIN) ? process.env.V4_PLUGIN : join(HERE, '..', process.env.V4_PLUGIN))
|
|
41
|
-
: pathToFileURL(join(HERE, '..', 'vibe-math-v4', 'vibe-math-v4.js'))
|
|
42
|
-
const PRESET_DIR = dirname(fileURLToPath(PLUGIN))
|
|
43
|
-
const MODULE = await import(pathToFileURL(join(PRESET_DIR, 'math-computation.js')).href)
|
|
44
|
-
const ENGINES_MOD = await import(pathToFileURL(join(PRESET_DIR, 'math-engines.js')).href)
|
|
45
|
-
const MATH_ENGINES = ENGINES_MOD.MATH_ENGINES
|
|
46
|
-
|
|
47
|
-
let passed = 0, failed = 0
|
|
48
|
-
const failures = []
|
|
49
|
-
const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
|
|
50
|
-
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
51
|
-
const section = t => console.log('\n[' + t + ']')
|
|
52
|
-
const promptOf = e => (e && e.blocks && e.blocks[0] && e.blocks[0].text) || ''
|
|
53
|
-
const readIf = p => (existsSync(p) ? readFileSync(p, 'utf8') : '')
|
|
54
|
-
|
|
55
|
-
// ===============================================================
|
|
56
|
-
// FAKE ENGINE SEAM + MOCK HOST
|
|
57
|
-
// ===============================================================
|
|
58
|
-
function makeSubprocess(state) {
|
|
59
|
-
const base = c => String(c).split(/[\\/]/).pop().replace(/\.(cmd|exe|sh|bat)$/i, '')
|
|
60
|
-
const executableFor = cmd => {
|
|
61
|
-
const b = base(cmd)
|
|
62
|
-
if (state.cliCommands.indexOf(String(cmd)) !== -1 || state.cliCommands.indexOf(b) !== -1) return 'X:/fake/bin/' + b
|
|
63
|
-
const known = []
|
|
64
|
-
for (const name of Object.keys(MATH_ENGINES)) { const cands = ENGINES_MOD.mathEngineCandidates(name); if (cands) for (const x of cands) known.push(x) }
|
|
65
|
-
if (known.indexOf(b) === -1) throw new Error('not found: ' + cmd)
|
|
66
|
-
if (state.installed.indexOf(b) === -1) throw new Error('not found: ' + cmd)
|
|
67
|
-
return 'X:/fake/bin/' + b
|
|
68
|
-
}
|
|
69
|
-
const engineOf = argv => {
|
|
70
|
-
const b = base(argv[0])
|
|
71
|
-
for (const name of Object.keys(MATH_ENGINES)) {
|
|
72
|
-
const cands = ENGINES_MOD.mathEngineCandidates(name)
|
|
73
|
-
if (cands && cands.indexOf(b) !== -1) return MATH_ENGINES[name]
|
|
74
|
-
}
|
|
75
|
-
return null
|
|
76
|
-
}
|
|
77
|
-
const classify = argv => {
|
|
78
|
-
const d = engineOf(argv)
|
|
79
|
-
const rest = argv.slice(1).join(' ')
|
|
80
|
-
if (!d) return 'run'
|
|
81
|
-
if (d.versionArgv && rest === d.versionArgv.join(' ')) return 'version'
|
|
82
|
-
if (d.licenseProbe && rest === d.licenseProbe.argv.join(' ')) return 'licence'
|
|
83
|
-
if (d.probeCode && rest.indexOf(d.probeCode) !== -1) return 'packages'
|
|
84
|
-
return 'run'
|
|
85
|
-
}
|
|
86
|
-
return {
|
|
87
|
-
async resolveExecutable(cmd) {
|
|
88
|
-
if (!state.subprocessAvailable) throw new Error('no subprocess')
|
|
89
|
-
return executableFor(cmd)
|
|
90
|
-
},
|
|
91
|
-
spawn(spec) {
|
|
92
|
-
const argv0 = String((spec.argv && spec.argv[0]) || '')
|
|
93
|
-
if (/powershell|cmd\.exe|\/bin\/sh|(^|\/)sh$/i.test(argv0)) {
|
|
94
|
-
state.shellCalls.push(String(spec.argv[spec.argv.length - 1] || ''))
|
|
95
|
-
const empty = ''
|
|
96
|
-
return {
|
|
97
|
-
done: Promise.resolve({ exitCode: 0, signal: null }),
|
|
98
|
-
collected: { stdout: { readFrom: () => ({ text: empty, nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: empty, nextOffset: 0, lossy: false }) } },
|
|
99
|
-
terminate() {},
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
state.spawns.push({ argv: spec.argv.slice(), cwd: spec.cwd, graceMs: spec.graceMs, stdoutCap: spec.stdio && spec.stdio.stdout && spec.stdio.stdout.maxBytes })
|
|
103
|
-
const kind = classify(spec.argv)
|
|
104
|
-
const collect = (stdout, stderr) => ({
|
|
105
|
-
done: Promise.resolve({ exitCode: 0, signal: null }),
|
|
106
|
-
collected: {
|
|
107
|
-
stdout: { readFrom: () => ({ text: stdout, nextOffset: stdout.length, lossy: false }) },
|
|
108
|
-
stderr: { readFrom: () => ({ text: stderr, nextOffset: stderr.length, lossy: false }) },
|
|
109
|
-
},
|
|
110
|
-
terminate() {},
|
|
111
|
-
})
|
|
112
|
-
const record = { kind }
|
|
113
|
-
if (kind === 'version') return collect(base(spec.argv[0]) + ' ' + state.version + '\n', '')
|
|
114
|
-
if (kind === 'licence') return collect(state.licensed ? '1\n' : '0\n', '')
|
|
115
|
-
if (kind === 'packages') {
|
|
116
|
-
const joined = spec.argv.join(' ')
|
|
117
|
-
const pairs = []
|
|
118
|
-
for (const p of Object.keys(state.packages)) if (joined.indexOf(p) !== -1) pairs.push(p + ':' + (state.packages[p] ? 'ok' : 'missing'))
|
|
119
|
-
return collect(pairs.join('|') + '\n', '')
|
|
120
|
-
}
|
|
121
|
-
// run (also: the package-manager argv an approved install plan produces)
|
|
122
|
-
if (state.hang) {
|
|
123
|
-
return {
|
|
124
|
-
done: new Promise(() => {}),
|
|
125
|
-
collected: { stdout: { readFrom: () => ({ text: 'partial', nextOffset: 7, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
|
|
126
|
-
terminate() { state.terminated.push(spec.argv.join(' ')) },
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
if (state.argError) {
|
|
130
|
-
return {
|
|
131
|
-
done: Promise.resolve({ exitCode: 2, signal: null }),
|
|
132
|
-
collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: state.argError, nextOffset: state.argError.length, lossy: false }) } },
|
|
133
|
-
terminate() {},
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
if (state.exit !== 0) {
|
|
137
|
-
return {
|
|
138
|
-
done: Promise.resolve({ exitCode: state.exit, signal: null }),
|
|
139
|
-
collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: 'boom\n', nextOffset: 5, lossy: false }) } },
|
|
140
|
-
terminate() {},
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const out = state.stdoutBytes > 0 ? 'x'.repeat(state.stdoutBytes) : 'ran-ok\n'
|
|
144
|
-
record.argv = spec.argv.slice()
|
|
145
|
-
state.runs.push(record)
|
|
146
|
-
// Defer/release hook (§16): the FIRST engine run is held INSIDE spawn until the test releases
|
|
147
|
-
// it, so a second session's call can be driven to completion while the first is in flight.
|
|
148
|
-
if (state.holdFirstRun) {
|
|
149
|
-
state.holdFirstRun = false
|
|
150
|
-
state.heldRuns++
|
|
151
|
-
let release
|
|
152
|
-
const gate = new Promise(r => { release = r })
|
|
153
|
-
state.releaseFirst = () => release()
|
|
154
|
-
return {
|
|
155
|
-
done: gate.then(() => ({ exitCode: 0, signal: null })),
|
|
156
|
-
collected: {
|
|
157
|
-
stdout: { readFrom: () => ({ text: out, nextOffset: out.length, lossy: false }) },
|
|
158
|
-
stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) },
|
|
159
|
-
},
|
|
160
|
-
terminate() {},
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return collect(out, '')
|
|
164
|
-
},
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function makeHost(opts = {}) {
|
|
169
|
-
const WS = mkdtempSync(join(tmpdir(), 'vibe-v4-math-'))
|
|
170
|
-
const toolRegs = [], cmdRegs = [], spawns = [], followups = [], listeners = {}
|
|
171
|
-
const state = {
|
|
172
|
-
installed: (opts.installed === undefined ? ['python3', 'python'] : opts.installed).slice(),
|
|
173
|
-
cliCommands: (opts.cliCommands || ['node']).slice(),
|
|
174
|
-
packages: Object.assign({}, opts.packages || {}),
|
|
175
|
-
licensed: opts.licensed !== false,
|
|
176
|
-
version: opts.version || '1.2.3',
|
|
177
|
-
exit: opts.exit === undefined ? 0 : opts.exit,
|
|
178
|
-
hang: !!opts.hang,
|
|
179
|
-
stdoutBytes: opts.stdoutBytes || 0,
|
|
180
|
-
argError: opts.argError || null,
|
|
181
|
-
subprocessAvailable: opts.subprocessAvailable !== false,
|
|
182
|
-
resolveOnly: !!opts.resolveOnly,
|
|
183
|
-
// audit-R2 lens-2 §19: these three WERE being passed by the test but dropped here, so the
|
|
184
|
-
// fixture never reached `fs.listDir` and the retention assertion could not fire. A state
|
|
185
|
-
// whitelist silently swallowing a fixture field is exactly the "test that cannot fail" trap.
|
|
186
|
-
listDirEntries: opts.listDirEntries || null,
|
|
187
|
-
noListDir: !!opts.noListDir,
|
|
188
|
-
listDirCalls: [],
|
|
189
|
-
spawns: [], terminated: [], runs: [], shellCalls: [], holdFirstRun: !!opts.holdFirstRun, heldRuns: 0, releaseFirst: null,
|
|
190
|
-
}
|
|
191
|
-
const subprocess = makeSubprocess(state)
|
|
192
|
-
const subprocessSurface = state.resolveOnly ? { resolveExecutable: subprocess.resolveExecutable } : subprocess
|
|
193
|
-
const ROOT = { id: 'sess-A', session: { id: 'sess-A', header: { cwd: WS, parentSession: undefined } } }
|
|
194
|
-
// A SECOND root agent on the same ctx: v4 keys sessions by root agent id, so this is a second
|
|
195
|
-
// session with its OWN `params` (audit C #2 needs two sessions in one plugin instance).
|
|
196
|
-
const ROOT2 = { id: 'sess-B', session: { id: 'sess-B', header: { cwd: WS, parentSession: undefined } } }
|
|
197
|
-
const ctx = {
|
|
198
|
-
get(name) { return name === 'subprocess' && state.subprocessAvailable ? subprocessSurface : undefined },
|
|
199
|
-
on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
|
|
200
|
-
effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
|
|
201
|
-
logger: { info() {}, warn() {}, error() {} },
|
|
202
|
-
timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
|
|
203
|
-
tools: { register(s) { toolRegs.push(s) } },
|
|
204
|
-
commands: { register(s) { cmdRegs.push(s) } },
|
|
205
|
-
subagents: {
|
|
206
|
-
list() { return ['spawn'] },
|
|
207
|
-
async startContinuable({ label, request }) { const id = 'c' + (spawns.length + 1); spawns.push({ label, request, childId: id }); return { childId: id } },
|
|
208
|
-
async sendMessage(parent, childId, blocks) { followups.push({ childId, blocks }) },
|
|
209
|
-
async interrupt() {},
|
|
210
|
-
},
|
|
211
|
-
agents: { roots() { return [] }, get(id) { return id === 'sess-A' ? ROOT : (id === 'sess-B' ? ROOT2 : undefined) } },
|
|
212
|
-
fs: {
|
|
213
|
-
async resolve(rel, o) { const b = (o && o.cwd) || WS; return { targetKey: (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/')) } },
|
|
214
|
-
async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
|
|
215
|
-
async readText(t) { return readFileSync(t.targetKey, 'utf8') },
|
|
216
|
-
async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
|
|
217
|
-
// `noListDir` removes the METHOD (not merely its result), so the plugin's conditional
|
|
218
|
-
// pass-through can be exercised: the module must then never be given a listDir at all.
|
|
219
|
-
...(opts.noListDir ? {} : { async listDir(t) { state.listDirCalls.push(t && t.targetKey); if (state.listDirEntries) return state.listDirEntries; return [] } }),
|
|
220
|
-
},
|
|
221
|
-
}
|
|
222
|
-
const projectRoot = join(WS, 'VibeMath', 'Projects', 'default')
|
|
223
|
-
const h = {
|
|
224
|
-
WS, ctx, state, ROOT, ROOT2, projectRoot, toolRegs, spawns, followups, cmdRegs, listeners,
|
|
225
|
-
async cmd(rawInput) { const c = cmdRegs.find(x => x.name === 'v4'); if (!c) throw new Error('no /v4'); return await c.handler({ agent: ROOT, rawInput }) },
|
|
226
|
-
async callTool(n, a, agent) { const s = toolRegs.find(x => x.name === n); if (!s) throw new Error('no tool ' + n); return JSON.parse(await s.execute(a || {}, { agent: agent || ROOT })) },
|
|
227
|
-
async math(a) { return await h.callTool('math_computation', a) },
|
|
228
|
-
async prompts(which, rId, arg) { return (await h.callTool('vibe_v4_prompts', Object.assign({ which, member: rId }, arg || {}))).text },
|
|
229
|
-
resAgent: cid => ({ id: cid, session: { id: cid, header: { cwd: WS, parentSession: 'sess-A' } } }),
|
|
230
|
-
ridOfChild(cid) { for (const sp of spawns) if (sp.childId === cid) return sp.label; return '' },
|
|
231
|
-
childOf(rId) { for (const sp of spawns) if (sp.label === rId) return sp.childId; return '' },
|
|
232
|
-
fireEnd(cid, reply) {
|
|
233
|
-
const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
|
|
234
|
-
for (const fn of (listeners['subagent/end'] || [])) fn({ id: cid, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: blocks })
|
|
235
|
-
},
|
|
236
|
-
cleanup() { try { rmSync(WS, { recursive: true, force: true }) } catch (e) { /* best effort */ } },
|
|
237
|
-
}
|
|
238
|
-
return h
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
async function mount(opts) {
|
|
242
|
-
const h = makeHost(opts)
|
|
243
|
-
const mod = await import(PLUGIN.href + '?t=' + Date.now() + Math.random())
|
|
244
|
-
;(mod.default || mod).apply(h.ctx)
|
|
245
|
-
return h
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
/** Start a run so a session exists, then park the heartbeat (these tests drive calls explicitly). */
|
|
249
|
-
async function live(opts) {
|
|
250
|
-
const h = await mount(opts)
|
|
251
|
-
const before = h.spawns.length
|
|
252
|
-
await h.callTool('vibe_v4_start', { problem: 'math_computation 接线验证', residentCount: 1 })
|
|
253
|
-
for (let i = 0; i < 60 && h.spawns.length < before + 1; i++) await sleep(10)
|
|
254
|
-
for (const sp of h.spawns.slice(before)) { h.fireEnd(sp.childId, { summary: '初始见解。', solved: false, contextPct: 10 }); await sleep(30) }
|
|
255
|
-
await h.callTool('vibe_v4_set', { activityTimeoutMs: 600000 })
|
|
256
|
-
for (let i = 0; i < 40; i++) { const s = await h.callTool('vibe_v4_status', {}); if (s.phase === 'active') break; await sleep(20) }
|
|
257
|
-
return h
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// ===============================================================
|
|
261
|
-
section('1 the tool is registered ONCE, with the shared module\'s description/schema')
|
|
262
|
-
{
|
|
263
|
-
const h = await live()
|
|
264
|
-
const keys = ["name", "description", "parameters", "execute"]
|
|
265
|
-
const regs = h.toolRegs.filter(t => t.name === 'math_computation')
|
|
266
|
-
assert(regs.length === 1, '★ math_computation is registered exactly once (FREEZE §4: host.register is called once)')
|
|
267
|
-
assert(!!regs[0] && keys.every(k => k in regs[0]), 'the registration carries the {name,description,parameters,execute} shape')
|
|
268
|
-
assert(regs[0].description === MODULE.MATH_TOOL_DESCRIPTION, '★ the description is the module\'s MATH_TOOL_DESCRIPTION verbatim (no local re-spelling)')
|
|
269
|
-
assert(JSON.stringify(regs[0].parameters) === JSON.stringify(MODULE.MATH_TOOL_SCHEMA), '★ the parameters are the module\'s closed MATH_TOOL_SCHEMA verbatim')
|
|
270
|
-
assert(regs[0].parameters.properties.op.enum.join(',') === 'probe,run,receipt,install', 'the op set is the frozen four')
|
|
271
|
-
assert(regs[0].parameters.additionalProperties === false, 'the schema stays closed')
|
|
272
|
-
const names = h.toolRegs.map(t => t.name)
|
|
273
|
-
assert(names.indexOf('math_computation') !== -1 && names.filter(n => n === 'math_computation').length === 1, 'it appears once in the tool list (the tool-count snapshot +1)')
|
|
274
|
-
// param visibility: paramsKeys is Object.keys(params) — the six keys must be there.
|
|
275
|
-
const st = await h.callTool('vibe_v4_status', {})
|
|
276
|
-
for (const k of MODULE.MATH_PARAM_NAMES) assert(st.paramsKeys.indexOf(k) !== -1, '★ status().paramsKeys exposes ' + k)
|
|
277
|
-
// round-9 (F2): in a fresh workspace there is no paper state, so `paper.dir` is a PROJECTION - the
|
|
278
|
-
// response must say so (reading status touches no fs and creates no directory).
|
|
279
|
-
assert(!!st.paper, '* status.paper exists (the gate below must NOT silently skip its own assertions)')
|
|
280
|
-
if (st.paper) {
|
|
281
|
-
assert(st.paper.dirProjected === true && st.paper.dirSource === 'projection', '★ a fresh status labels paper.dir as a PROJECTION (not a real directory)')
|
|
282
|
-
assert(st.paper.readSideEffect === false, '★ the paper view declares it has no read side effects')
|
|
283
|
-
assert(!existsSync(join(h.projectRoot, 'Paper')), '★ merely reading status did NOT create Paper/')
|
|
284
|
-
}
|
|
285
|
-
// round-9 (P2/D4): the roster is single-sourced - `status` exposes a version that BUMPS whenever the
|
|
286
|
-
// roster really changes (mid-flight add/remove is possible: removeMember reconciles in-flight work,
|
|
287
|
-
// addMember has no verify/meeting gate), and the participant set is reported as a FROZEN snapshot.
|
|
288
|
-
assert(typeof st.rosterVersion === 'number', '* status.rosterVersion is a number (the gate below must NOT silently skip its own assertions)')
|
|
289
|
-
if (typeof st.rosterVersion === 'number') {
|
|
290
|
-
assert(st.rosterVersion >= 0 && 'frozenParticipants' in st, '★ status exposes rosterVersion + the frozen participant set')
|
|
291
|
-
const v0 = st.rosterVersion
|
|
292
|
-
const hire = await h.callTool('vibe_v4_add_member', { direction: 'P2 roster-version assertion' })
|
|
293
|
-
const st2 = await h.callTool('vibe_v4_status', {})
|
|
294
|
-
assert(st2.rosterVersion > v0, '★ hiring mid-flight bumps rosterVersion (' + v0 + ' -> ' + st2.rosterVersion + ')')
|
|
295
|
-
assert(st2.frozenParticipants === null || Array.isArray(st2.frozenParticipants), '★ with nothing frozen the field is explicitly null (not a stale set)')
|
|
296
|
-
if (hire && hire.ok && hire.id) {
|
|
297
|
-
await h.callTool('vibe_v4_remove_member', { id: hire.id })
|
|
298
|
-
const st3 = await h.callTool('vibe_v4_status', {})
|
|
299
|
-
assert(st3.rosterVersion > st2.rosterVersion, '★ firing mid-flight bumps rosterVersion too (' + st2.rosterVersion + ' -> ' + st3.rosterVersion + ')')
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
h.cleanup()
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// ===============================================================
|
|
306
|
-
section('2 op=probe: installed engines are detected, none ⇒ not available')
|
|
307
|
-
{
|
|
308
|
-
const h = await live({ installed: ['python3', 'python', 'octave'] })
|
|
309
|
-
const p = await h.math({ op: 'probe' })
|
|
310
|
-
assert(p.ok === true && p.op === 'probe' && p.available === true, 'probe reports ok/available when an engine resolves')
|
|
311
|
-
const py = p.engines.find(e => e.name === 'python')
|
|
312
|
-
assert(!!py && py.version === '1.2.3' && /python/.test(py.path), '★ the version probe is parsed (python 1.2.3) and the path reported')
|
|
313
|
-
assert(p.engines.some(e => e.name === 'octave'), 'every installed P1 engine is listed')
|
|
314
|
-
assert(!p.engines.some(e => e.name === 'cli'), 'cli is never auto-detected (it needs a caller command)')
|
|
315
|
-
h.cleanup()
|
|
316
|
-
const h2 = await live({ installed: [] })
|
|
317
|
-
const p2 = await h2.math({ op: 'probe' })
|
|
318
|
-
assert(p2.ok === false && p2.code === 'MATH_ENGINE_NOT_FOUND' && p2.next && p2.next.kind === 'user-install', '★ nothing installed ⇒ MATH_ENGINE_NOT_FOUND + next.kind=user-install')
|
|
319
|
-
assert(p2.next.perOs && Object.keys(p2.next.perOs).length >= 3 && Object.values(p2.next.perOs).every(v => !!v), '★ …with non-empty per-OS install commands')
|
|
320
|
-
h2.cleanup()
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// ===============================================================
|
|
324
|
-
section('3 op=run: argv assembly, cwd, receipt + argv echo, project file mode')
|
|
325
|
-
{
|
|
326
|
-
const h = await live({ installed: ['python3', 'python'] })
|
|
327
|
-
const r = await h.math({ op: 'run', engine: 'python', mode: 'expr', expr: '2**10' })
|
|
328
|
-
assert(r.ok === true && r.engine === 'python' && r.exit === 0, 'a fake python run returns ok/exit 0')
|
|
329
|
-
const sp = h.state.spawns[h.state.spawns.length - 1]
|
|
330
|
-
const norm = p => String(p).replace(/\\/g, '/').replace(/\/$/, '')
|
|
331
|
-
assert(norm(sp.cwd) === norm(h.projectRoot), '★ spawn cwd is the PROJECT root (' + JSON.stringify(sp.cwd) + ' vs ' + JSON.stringify(h.projectRoot) + ')')
|
|
332
|
-
const runArgv = h.state.runs[h.state.runs.length - 1].argv
|
|
333
|
-
assert(runArgv.length === 3 && runArgv[0] === 'X:/fake/bin/python3' && runArgv[1] === '-c' && runArgv[2] === '2**10', '★ mode=expr puts the expression in exactly ONE argv element (no shell quoting, no split)')
|
|
334
|
-
assert(JSON.stringify(r.argv) === JSON.stringify(runArgv), '★ the return shell echoes the ACTUAL executed argv')
|
|
335
|
-
assert(r.receipt && r.receipt.dir && existsSync(join(h.projectRoot, r.receipt.dir, 'receipt.json')), 'the receipt is archived under Computation/<id>/')
|
|
336
|
-
const rec = JSON.parse(readIf(join(h.projectRoot, r.receipt.dir, 'receipt.json')))
|
|
337
|
-
assert(JSON.stringify(rec.argv) === JSON.stringify(runArgv), '★ receipt.json.argv equals the executed argv element-by-element')
|
|
338
|
-
assert(rec.preset === 'vibe-math-v4', '★ the receipt carries designator=vibe-math-v4')
|
|
339
|
-
// mode=code: the script is materialised and its ABSOLUTE path is the last argv element
|
|
340
|
-
const r2 = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1+1)\n' })
|
|
341
|
-
assert(r2.ok === true, 'mode=code runs')
|
|
342
|
-
const argv2 = h.state.runs[h.state.runs.length - 1].argv
|
|
343
|
-
assert(argv2.length === 2 && /\.py$/.test(argv2[1]) && isAbsolute(argv2[1]), '★ mode=code passes the script as an absolute path (one element)')
|
|
344
|
-
assert(existsSync(argv2[1]) && /print\(1\+1\)/.test(readIf(argv2[1])), '★ the script file really exists on disk with the code (receipts are re-runnable)')
|
|
345
|
-
// mode=file: an in-project file is allowed
|
|
346
|
-
mkdirSync(join(h.projectRoot, 'tmp'), { recursive: true })
|
|
347
|
-
writeFileSync(join(h.projectRoot, 'tmp', 'calc.py'), 'print(2+2)\n')
|
|
348
|
-
const r3 = await h.math({ op: 'run', engine: 'python', mode: 'file', file: 'tmp/calc.py' })
|
|
349
|
-
assert(r3.ok === true, 'mode=file with an in-project script runs')
|
|
350
|
-
h.cleanup()
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// ===============================================================
|
|
354
|
-
section('4 refusals: path escape, cli without command, cli+expr, policy, missing engine, no subprocess')
|
|
355
|
-
{
|
|
356
|
-
const h = await live({ installed: ['python3', 'python'] })
|
|
357
|
-
const esc = await h.math({ op: 'run', engine: 'python', mode: 'file', file: '../../../../etc/passwd' })
|
|
358
|
-
assert(esc.ok === false && esc.code === 'MATH_REFUSED', '★ a file outside the project root is refused (MATH_REFUSED)')
|
|
359
|
-
const esc2 = await h.math({ op: 'run', engine: 'python', mode: 'file', file: 'C:\\Windows\\evil.py' })
|
|
360
|
-
assert(esc2.ok === false && esc2.code === 'MATH_REFUSED', '★ an absolute path is refused too')
|
|
361
|
-
const noCmd = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: 'echo hi\n' })
|
|
362
|
-
assert(noCmd.ok === false && noCmd.code === 'MATH_REFUSED', '★ engine=cli without cli.command is refused')
|
|
363
|
-
const cliExpr = await h.math({ op: 'run', engine: 'cli', mode: 'expr', expr: '1+1', cli: { command: 'node' } })
|
|
364
|
-
assert(cliExpr.ok === false && cliExpr.code === 'MATH_REFUSED', '★ engine=cli + mode=expr is refused (cli has no eval template)')
|
|
365
|
-
const before = h.state.spawns.length
|
|
366
|
-
const notThere = await h.math({ op: 'run', engine: 'octave', mode: 'code', code: 'x=1\n' })
|
|
367
|
-
assert(notThere.ok === false && notThere.code === 'MATH_ENGINE_NOT_FOUND', '★ a missing engine reports MATH_ENGINE_NOT_FOUND')
|
|
368
|
-
// round-9 (real-engine): the guide is always per-OS; `command` itself is only offered when that
|
|
369
|
-
// OS's package manager is actually resolvable (a command that cannot run is misleading).
|
|
370
|
-
assert(notThere.next && notThere.next.kind === 'user-install' && notThere.next.perOs && notThere.next.perOs.windows, '★ …with per-OS user-install guidance (next.kind=user-install)')
|
|
371
|
-
assert(notThere.next.packageManagerAvailable === false ? notThere.next.command === '' : typeof notThere.next.command === 'string', '★ command ⇔ package-manager availability (never a command that cannot run)')
|
|
372
|
-
assert(h.state.spawns.length === before, 'a missing engine spawns nothing')
|
|
373
|
-
// (a) no subprocess service at all ⇒ the host's capability flag short-circuits to
|
|
374
|
-
// MATH_NO_SUBPROCESS (not a misleading ENGINE_NOT_FOUND + "install python" guide)
|
|
375
|
-
const h2 = await live({ installed: ['python3'], subprocessAvailable: false })
|
|
376
|
-
const noSub = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
|
|
377
|
-
assert(noSub.ok === false && noSub.code === 'MATH_NO_SUBPROCESS' && !!noSub.message, '★ a host with no subprocess service reports MATH_NO_SUBPROCESS (got ' + noSub.code + ')')
|
|
378
|
-
const probeNoSub = await h2.math({ op: 'probe' })
|
|
379
|
-
assert(probeNoSub.ok === false && probeNoSub.code === 'MATH_NO_SUBPROCESS', '★ …also for op=probe (the flag is checked before engine resolution)')
|
|
380
|
-
h2.cleanup()
|
|
381
|
-
// (b) a service that RESOLVES but cannot SPAWN ⇒ the module's MATH_NO_SUBPROCESS (the preset hands
|
|
382
|
-
// the module a null spawn result, which is its documented "host cannot execute" signal)
|
|
383
|
-
const h3 = await live({ installed: ['python3'], cliCommands: ['node'], resolveOnly: true })
|
|
384
|
-
const noSpawn = await h3.math({ op: 'run', engine: 'cli', mode: 'code', code: '1\n', cli: { command: 'node', argv: ['-e', '1'] } })
|
|
385
|
-
assert(noSpawn.ok === false && noSpawn.code === 'MATH_NO_SUBPROCESS', '★ a host that resolves but cannot spawn reports MATH_NO_SUBPROCESS (got ' + noSpawn.code + ')')
|
|
386
|
-
h3.cleanup(); h.cleanup()
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// ===============================================================
|
|
390
|
-
section('5 timeout ACTIVELY terminates; non-zero exit is not success')
|
|
391
|
-
{
|
|
392
|
-
const h = await live({ installed: ['python3', 'python'], hang: true })
|
|
393
|
-
await h.callTool('vibe_v4_set', { mathTimeoutMs: 1000 })
|
|
394
|
-
const t0 = Date.now()
|
|
395
|
-
const r = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'while True: pass\n' })
|
|
396
|
-
const dt = Date.now() - t0
|
|
397
|
-
assert(r.ok === false && r.code === 'MATH_TIMEOUT' && r.timedOut === true, '★ a hanging engine reports MATH_TIMEOUT/timedOut')
|
|
398
|
-
assert(h.state.terminated.length >= 1, '★ the timeout path really called handle.terminate() (an active kill, not a report)')
|
|
399
|
-
assert(dt >= 900 && dt < 5000, 'the budget honoured mathTimeoutMs (elapsed ' + dt + 'ms)')
|
|
400
|
-
h.cleanup()
|
|
401
|
-
const h2 = await live({ installed: ['python3', 'python'], exit: 3 })
|
|
402
|
-
const r2 = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'boom\n' })
|
|
403
|
-
assert(r2.ok === false && r2.code === 'MATH_NONZERO_EXIT' && r2.exit === 3, '★ a non-zero exit is MATH_NONZERO_EXIT{exit:3}, never ok:true')
|
|
404
|
-
assert(/boom/.test(String(r2.stderr || '')) || /boom/.test(JSON.stringify(r2)), 'the stderr tail is returned')
|
|
405
|
-
assert(r2.argv && r2.argv.length >= 2, '★ the failing call still echoes its argv (guards #19)')
|
|
406
|
-
h2.cleanup()
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// ===============================================================
|
|
410
|
-
section('6 oversized output: complete file on disk, truncated reply')
|
|
411
|
-
{
|
|
412
|
-
const h = await live({ installed: ['python3', 'python'], stdoutBytes: 200 * 1024 })
|
|
413
|
-
const r = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print("x"*200000)\n' })
|
|
414
|
-
assert(r.ok === true, 'a large-output run still succeeds')
|
|
415
|
-
assert(r.truncated && r.truncated.stdout === true, '★ the REPLY is truncated (truncated.stdout:true)')
|
|
416
|
-
assert(Array.isArray(r.warnings) && r.warnings.some(w => w.code === 'OUTPUT_TRUNCATED'), '★ …and warned (OUTPUT_TRUNCATED)')
|
|
417
|
-
const outPath = join(h.projectRoot, r.receipt.dir, 'stdout.txt')
|
|
418
|
-
assert(existsSync(outPath) && readFileSync(outPath, 'utf8').length >= 200 * 1024, '★ the COMPLETE output is archived on disk (>64KB), so the receipt is re-checkable')
|
|
419
|
-
assert(h.state.spawns.some(s => s.stdoutCap >= 200 * 1024), '★ the preset passes the module\'s wide stdio cap through to the host (no silent 64KB collection loss)')
|
|
420
|
-
h.cleanup()
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// ===============================================================
|
|
424
|
-
section('7 commercial engine: licence probe gates it, never installable')
|
|
425
|
-
{
|
|
426
|
-
const h = await live({ installed: ['matlab'], licensed: false })
|
|
427
|
-
const r = await h.math({ op: 'run', engine: 'matlab', mode: 'expr', expr: '1+1' })
|
|
428
|
-
assert(r.ok === false && r.code === 'MATH_ENGINE_LICENSE_REQUIRED', '★ an installed but unlicensed commercial engine reports LICENSE_REQUIRED')
|
|
429
|
-
assert(r.next && r.next.kind === 'vendor' && /mathworks/.test(JSON.stringify(r.next)), '★ …with the vendor link (never an install plan)')
|
|
430
|
-
const inst = await h.math({ op: 'install', engine: 'matlab', packages: ['x'], dryRun: true })
|
|
431
|
-
assert(inst.ok === false && inst.code === 'MATH_REFUSED', '★ op=install on a commercial engine is REFUSED (the preset never installs it)')
|
|
432
|
-
h.cleanup()
|
|
433
|
-
const h2 = await live({ installed: ['matlab'], licensed: true })
|
|
434
|
-
const ok = await h2.math({ op: 'run', engine: 'matlab', mode: 'expr', expr: '1+1' })
|
|
435
|
-
assert(ok.ok === true, 'with a valid licence the same call runs')
|
|
436
|
-
h2.cleanup()
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// ===============================================================
|
|
440
|
-
section('8 cli engine: ON by default, and the two ways to disable it')
|
|
441
|
-
{
|
|
442
|
-
const h = await live({ installed: ['python3', 'python'], cliCommands: ['node'] })
|
|
443
|
-
const r = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: '1+1\n', cli: { command: 'node', argv: ['-e', 'console.log(1+1)'] } })
|
|
444
|
-
assert(r.ok === true, '★ engine=cli runs with the DEFAULT parameters')
|
|
445
|
-
const argv = h.state.runs[h.state.runs.length - 1].argv
|
|
446
|
-
// round-7 (finding 4 + live nuance): mode:'code' appends the archived script ONLY when the caller's
|
|
447
|
-
// argv has no program slot. This fixture passes `-e console.log(1+1)`, i.e. the caller's argv RUNS
|
|
448
|
-
// the code, so the script must NOT be appended (python/node would read it as an extra argument).
|
|
449
|
-
assert(argv.length === 3 && argv[1] === '-e' && argv[2] === 'console.log(1+1)', '★ cli argv is [<resolved command>, ...cli.argv] when the caller argv already runs code (nothing shell-wrapped)')
|
|
450
|
-
assert(r.cliScriptAppended === false, '★ the return shell reports cliScriptAppended=false for a caller-supplied program slot')
|
|
451
|
-
// …and with NO program slot the archived script IS appended (the "run this code" flow).
|
|
452
|
-
const r2 = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: '1+1\n', cli: { command: 'node', argv: [] } })
|
|
453
|
-
const argv2 = h.state.runs[h.state.runs.length - 1].argv
|
|
454
|
-
assert(r2.ok === true && r2.cliScriptAppended === true && /script\.txt$/.test(String(argv2[argv2.length - 1])), '★ …while an empty caller argv gets the archived script appended (' + JSON.stringify(argv2) + ')')
|
|
455
|
-
assert(String((r.engineInfo && r.engineInfo.name) || r.engine || '').indexOf('cli') === 0, '★ the engine is reported as cli:<command> (' + JSON.stringify(r.engineInfo && r.engineInfo.name) + ')')
|
|
456
|
-
assert(r.receipt && r.receipt.dir, '★ a cli run still leaves a receipt (the difference from the host shell)')
|
|
457
|
-
// (a) mathMode=typed refuses cli, with zero spawns
|
|
458
|
-
await h.callTool('vibe_v4_set', { mathMode: 'typed' })
|
|
459
|
-
const before = h.state.spawns.length
|
|
460
|
-
const typed = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: '1\n', cli: { command: 'node', argv: [] } })
|
|
461
|
-
assert(typed.ok === false && typed.code === 'MATH_REFUSED' && String(typed.next && typed.next.reason) === 'policy', '★ mathMode=typed refuses engine=cli with reason=policy')
|
|
462
|
-
assert(h.state.spawns.length === before, '★ …and spawns NOTHING (a policy refusal, not a late failure)')
|
|
463
|
-
await h.callTool('vibe_v4_set', { mathMode: 'typed+shell' })
|
|
464
|
-
// (b) removing cli from mathEngines refuses it too
|
|
465
|
-
await h.callTool('vibe_v4_set', { mathEngines: ['python', 'octave'] })
|
|
466
|
-
const before2 = h.state.spawns.length
|
|
467
|
-
const notAllowed = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: '1\n', cli: { command: 'node', argv: [] } })
|
|
468
|
-
assert(notAllowed.ok === false && notAllowed.code === 'MATH_REFUSED', '★ removing cli from mathEngines refuses it as well')
|
|
469
|
-
assert(h.state.spawns.length === before2, '★ …also with zero spawns')
|
|
470
|
-
h.cleanup()
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// ===============================================================
|
|
474
|
-
section('9 bad-argv is NOT collapsed into NONZERO_EXIT')
|
|
475
|
-
{
|
|
476
|
-
const h = await live({ installed: ['maple'], argError: "Error, unknown option '-q'\n" })
|
|
477
|
-
const r = await h.math({ op: 'run', engine: 'maple', mode: 'expr', expr: '1+1' })
|
|
478
|
-
assert(r.ok === false && r.code === 'MATH_ENGINE_BAD_ARGV', '★ a usage/option error exits as MATH_ENGINE_BAD_ARGV (not plain NONZERO_EXIT)')
|
|
479
|
-
assert(r.next && r.next.kind === 'engine-override', '★ next.kind=engine-override (an actionable hint)')
|
|
480
|
-
assert(JSON.stringify(r.next.argv) === JSON.stringify(h.state.spawns[h.state.spawns.length - 1].argv), '★ next.argv is the ACTUAL argv (what the receipt records)')
|
|
481
|
-
assert(/mathEngineOverride/.test(String(r.message || '')), '★ the message names mathEngineOverride')
|
|
482
|
-
h.cleanup()
|
|
483
|
-
const h2 = await live({ installed: ['python3', 'python'], exit: 3 })
|
|
484
|
-
const plain = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'x\n' })
|
|
485
|
-
assert(plain.code === 'MATH_NONZERO_EXIT', 'control: a plain non-zero exit stays MATH_NONZERO_EXIT (the branches are distinct)')
|
|
486
|
-
h2.cleanup()
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
// ===============================================================
|
|
490
|
-
section('10 package pre-check: found continues, missing stops before running')
|
|
491
|
-
{
|
|
492
|
-
const h = await live({ installed: ['python3', 'python'], packages: { numpy: true } })
|
|
493
|
-
const ok = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['numpy'] })
|
|
494
|
-
const pkgJson = JSON.stringify(ok.packages || {})
|
|
495
|
-
assert(ok.ok === true && pkgJson.indexOf('numpy') !== -1 && !/null|missing/.test(pkgJson), '★ a present package continues and is reported as usable (' + pkgJson + ')')
|
|
496
|
-
h.cleanup()
|
|
497
|
-
const h2 = await live({ installed: ['python3', 'python'], packages: { sympy: false } })
|
|
498
|
-
const before = h2.state.runs.length
|
|
499
|
-
const miss = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['sympy'] })
|
|
500
|
-
assert(miss.ok === false && miss.code === 'MATH_MISSING_PACKAGES', '★ a missing package reports MATH_MISSING_PACKAGES')
|
|
501
|
-
assert(!!miss.packages && !!miss.packages.found && miss.packages.found.sympy === null, '★ the missing list names the package (found.sympy === null)')
|
|
502
|
-
assert(miss.next && miss.next.kind === 'agent-install', '★ …and offers the agent-install path (plan → confirm)')
|
|
503
|
-
assert(h2.state.runs.length === before, '★ the script is NOT executed when a package is missing (guards #2)')
|
|
504
|
-
h2.cleanup()
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// ===============================================================
|
|
508
|
-
section('11 op=install: plan → confirm, no auto-install, audit record')
|
|
509
|
-
{
|
|
510
|
-
const h = await live({ installed: ['python3', 'python'], packages: { sympy: false } })
|
|
511
|
-
const before = h.state.spawns.length
|
|
512
|
-
const plan = await h.math({ op: 'install', engine: 'python', packages: ['sympy'], dryRun: true })
|
|
513
|
-
assert(plan.ok === true && plan.plan && typeof plan.planToken === 'string' && plan.planToken.length >= 8, '★ op=install dryRun returns a plan + planToken')
|
|
514
|
-
assert(h.state.spawns.length === before, '★ the plan installs NOTHING (zero spawns)')
|
|
515
|
-
const wrong = await h.math({ op: 'install', engine: 'python', packages: ['sympy'], confirm: 'not-the-token' })
|
|
516
|
-
assert(wrong.ok === false && wrong.code === 'MATH_REFUSED', '★ a wrong token is refused')
|
|
517
|
-
assert(h.state.spawns.length === before, '★ …with zero spawns')
|
|
518
|
-
const done = await h.math({ op: 'install', engine: 'python', packages: ['sympy'], confirm: plan.planToken })
|
|
519
|
-
assert(done.ok === true, '★ the correct token executes the plan exactly once')
|
|
520
|
-
assert(h.state.spawns.length === before + 1, '★ exactly one package-manager invocation (no retry)')
|
|
521
|
-
const audit = readIf(join(h.projectRoot, 'Computation', 'installs', plan.planToken + '.json'))
|
|
522
|
-
assert(!!audit && /"scope"/.test(audit) && /"manager"/.test(audit), '★ the audit record is written under Computation/installs/<token>.json')
|
|
523
|
-
h.cleanup()
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// ===============================================================
|
|
527
|
-
section('12 parameters: six keys normalized, visible, and switched through both surfaces')
|
|
528
|
-
{
|
|
529
|
-
const h = await live({ installed: ['python3', 'python'], cliCommands: ['node'] })
|
|
530
|
-
const setSpec = h.toolRegs.find(t => t.name === 'vibe_v4_set')
|
|
531
|
-
for (const k of MODULE.MATH_PARAM_NAMES) assert(!!setSpec.parameters.properties[k], '★ vibe_v4_set advertises ' + k)
|
|
532
|
-
assert(setSpec.parameters.additionalProperties === false, 'the set schema stays closed')
|
|
533
|
-
assert(JSON.stringify(setSpec.parameters.properties.mathMode.enum) === JSON.stringify(['typed+shell', 'typed']), 'mathMode is a closed enum in the schema')
|
|
534
|
-
// string coercion through the SLASH-COMMAND surface (raw strings)
|
|
535
|
-
const c = await h.cmd('set mathMode=typed mathTimeoutMs=5 mathPackages=a,b mathEngines=python,cli')
|
|
536
|
-
assert(c.kind === 'success', '/v4 set accepted the six math keys')
|
|
537
|
-
const st = await h.callTool('vibe_v4_status', {})
|
|
538
|
-
assert(/mathMode=typed/.test(st.params), '★ mathMode=typed survived the string surface')
|
|
539
|
-
assert(/mathTimeoutMs=1000/.test(st.params), '★ mathTimeoutMs=5 was clamped by the shared normalizer (1000)')
|
|
540
|
-
assert(/mathPackages=a,b/.test(st.params), '★ the comma string became an array')
|
|
541
|
-
assert(/mathEngines=python,cli/.test(st.params), '★ mathEngines=python,cli became a filtered array')
|
|
542
|
-
// invalid values degrade to the module defaults (never to something stronger)
|
|
543
|
-
await h.callTool('vibe_v4_set', { mathMode: 'bogus', mathComputation: 'nonsense', mathInstallScope: 'root' })
|
|
544
|
-
const st2 = await h.callTool('vibe_v4_status', {})
|
|
545
|
-
assert(/mathMode=typed\+shell/.test(st2.params), '★ an unknown mathMode degrades to typed+shell')
|
|
546
|
-
assert(/mathComputation=auto/.test(st2.params) && /mathInstallScope=user/.test(st2.params), '★ unknown enums degrade to the defaults')
|
|
547
|
-
// mathComputation=off is a TRUE no-op: the tool refuses and no prompt line appears
|
|
548
|
-
await h.callTool('vibe_v4_set', { mathComputation: 'off' })
|
|
549
|
-
const off = await h.math({ op: 'probe' })
|
|
550
|
-
assert(off.ok === false && off.code === 'MATH_NOT_AVAILABLE', '★ mathComputation=off makes the tool a no-op (MATH_NOT_AVAILABLE)')
|
|
551
|
-
const offPrompt = await h.prompts('normal', 'r-1')
|
|
552
|
-
assert(!/先 probe 再 run/.test(offPrompt) && !/math_computation/.test(offPrompt), '★ …and injects NO prompt line at all')
|
|
553
|
-
h.cleanup()
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// ===============================================================
|
|
557
|
-
section('13 prompt surface: zh + en availability lines, typed drops the shell fallback')
|
|
558
|
-
{
|
|
559
|
-
const h = await live({ installed: ['python3', 'python'] })
|
|
560
|
-
await h.callTool('vibe_v4_set', { mathMode: 'typed+shell' })
|
|
561
|
-
const normal = await h.prompts('normal', 'r-1')
|
|
562
|
-
assert(/先 probe 再 run/.test(normal), '★ the Chinese availability line reaches the round prompt')
|
|
563
|
-
assert(/未经工具归档/.test(normal), '★ …including the shell-fallback rule (mathMode=typed+shell)')
|
|
564
|
-
assert(/Probe first, then run/.test(normal) && /not tool-archived/.test(normal), '★ the English variant is injected as well (v4 prompts are bilingual)')
|
|
565
|
-
assert(/归档即引用/.test(normal) && /不得把计算结果当成/.test(normal), '★ the rule lines include 归档即引用 and the "not a proof" rule')
|
|
566
|
-
const hb = await h.prompts('heartbeat', 'r-1')
|
|
567
|
-
assert(/先 probe 再 run/.test(hb), 'the heartbeat prompt also carries the line')
|
|
568
|
-
await h.callTool('vibe_v4_set', { mathMode: 'typed' })
|
|
569
|
-
const typed = await h.prompts('normal', 'r-1')
|
|
570
|
-
assert(/先 probe 再 run/.test(typed), 'the availability line is still there in typed mode')
|
|
571
|
-
assert(!/未经工具归档/.test(typed) && !/not tool-archived/.test(typed), '★ mathMode=typed injects NO shell-fallback sentence in either language (prompts.md §4-5)')
|
|
572
|
-
const typedHb = await h.prompts('heartbeat', 'r-1')
|
|
573
|
-
assert(!/未经工具归档/.test(typedHb) && !/not tool-archived/.test(typedHb), '★ …and the heartbeat prompt is consistent with the mode too')
|
|
574
|
-
h.cleanup()
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// ===============================================================
|
|
578
|
-
section('14 persona: both text blocks carry the frozen tool line (byte-identical)')
|
|
579
|
-
{
|
|
580
|
-
const yml = readIf(PERSONA_YML)
|
|
581
|
-
const line = MODULE.MATH_PERSONA_TOOL_LINE
|
|
582
|
-
const hits = yml.split(line).length - 1
|
|
583
|
-
assert(hits === 2, '★ MATH_PERSONA_TOOL_LINE appears verbatim in BOTH persona blocks (found ' + hits + ')')
|
|
584
|
-
const first = yml.indexOf('- math_computation {op:')
|
|
585
|
-
assert(first !== -1 && yml.indexOf('- math_computation {op:', first + 1) !== -1, 'the two occurrences are in the two blocks (prefix + text)')
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// ===============================================================
|
|
589
|
-
section('15 P2a: scriptPath/scriptHash, the archive→edit→re-run workflow, and the persona rule')
|
|
590
|
-
{
|
|
591
|
-
const h = await live({ installed: ['python3', 'python'] })
|
|
592
|
-
// ① mode=code: the archived script original + its hash are in the return AND the receipt
|
|
593
|
-
const r1 = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(41+1)\n' })
|
|
594
|
-
assert(r1.ok === true && typeof r1.scriptPath === 'string' && r1.scriptPath.length > 0, '★ mode=code returns scriptPath (the archived script original)')
|
|
595
|
-
assert(/^[0-9a-f]{64}$/.test(String(r1.scriptHash || '')), '★ …and scriptHash (sha256 of the archived code)')
|
|
596
|
-
const rec1rel = join(h.projectRoot, r1.receipt.dir, 'receipt.json')
|
|
597
|
-
const rec1 = JSON.parse(readIf(rec1rel))
|
|
598
|
-
assert(rec1.scriptPath === r1.scriptPath && rec1.scriptHash === r1.scriptHash, '★ the receipt carries the same scriptPath/scriptHash (re-runnable evidence)')
|
|
599
|
-
assert(rec1.attempt === 1 && rec1.attemptDir === r1.receipt.dir, '★ attempt 1 archives into Computation/<id>/ (attemptDir = the run dir)')
|
|
600
|
-
// ② mode=file: edit the SAME source file and re-run ⇒ a NEW attempt + an explicit warning
|
|
601
|
-
mkdirSync(join(h.projectRoot, 'tmp'), { recursive: true })
|
|
602
|
-
const src = join(h.projectRoot, 'tmp', 'workflow.py')
|
|
603
|
-
writeFileSync(src, 'print(1)\n')
|
|
604
|
-
const f1 = await h.math({ op: 'run', engine: 'python', mode: 'file', file: 'tmp/workflow.py' })
|
|
605
|
-
assert(f1.ok === true && f1.attempt === 1, 'precondition: the first mode=file run is attempt 1')
|
|
606
|
-
const firstDir = f1.receipt.dir, firstHash = f1.scriptHash
|
|
607
|
-
writeFileSync(src, 'print(2)\n') // the agent edits the archived/original code
|
|
608
|
-
const f2 = await h.math({ op: 'run', engine: 'python', mode: 'file', file: 'tmp/workflow.py' })
|
|
609
|
-
assert(f2.ok === true, 'the edited script re-runs')
|
|
610
|
-
assert(f2.receipt.dir !== firstDir && f2.attempt === 2 && /attempts\/2$/.test(String(f2.receipt.dir)), '★ the re-run is exactly attempt 2 in a NEW directory (append-only: attempts/<n>), never an overwrite (' + f2.receipt.dir + ')')
|
|
611
|
-
assert(f2.scriptChanged === true, '★ scriptChanged:true — the receipt for the current code differs from the previous one')
|
|
612
|
-
assert(!!f2.previousReceipt && String(f2.previousReceipt.scriptHash) === String(firstHash), '★ the RETURN SHELL also carries previousReceipt{runId,attempt,scriptHash} (not only the receipt file)')
|
|
613
|
-
assert(Array.isArray(f2.warnings) && f2.warnings.some(w => w.code === 'SCRIPT_CHANGED_SINCE_LAST_RECEIPT'), '★ warnings carry SCRIPT_CHANGED_SINCE_LAST_RECEIPT')
|
|
614
|
-
assert(String(f2.scriptHash) !== String(firstHash), '★ the new attempt records the NEW scriptHash')
|
|
615
|
-
const rec2 = JSON.parse(readIf(join(h.projectRoot, f2.receipt.dir, 'receipt.json')))
|
|
616
|
-
assert(rec2.previousReceipt && String(rec2.previousReceipt.scriptHash) === String(firstHash) && rec2.previousReceipt.attempt === 1 && !!rec2.previousReceipt.runId, '★ the attempt-2 receipt records previousReceipt{runId,attempt,scriptHash} pointing at attempt 1 (the old evidence is explicitly superseded)')
|
|
617
|
-
assert(existsSync(join(h.projectRoot, firstDir, 'receipt.json')) && existsSync(join(h.projectRoot, f2.receipt.dir, 'receipt.json')), '★ BOTH receipts stay on disk (the old one is not deleted or rewritten)')
|
|
618
|
-
// ③ the persona rule is in BOTH blocks, in both languages
|
|
619
|
-
const yml = readIf(PERSONA_YML)
|
|
620
|
-
for (const [name, line] of [['zh', MODULE.MATH_ARCHIVE_WORKFLOW_LINE], ['en', MODULE.MATH_ARCHIVE_WORKFLOW_LINE_EN]]) {
|
|
621
|
-
const hits = yml.split(line).length - 1
|
|
622
|
-
assert(hits === 2, '★ the ' + name + ' archive-workflow rule appears verbatim in BOTH persona blocks (found ' + hits + ')')
|
|
623
|
-
}
|
|
624
|
-
// …and the dynamic availability line carries it too (the module appends it)
|
|
625
|
-
const prompt = await h.prompts('normal', 'r-1')
|
|
626
|
-
assert(prompt.indexOf(MODULE.MATH_ARCHIVE_WORKFLOW_LINE.trim()) !== -1, '★ the injected availability line carries the archive-workflow rule as well')
|
|
627
|
-
h.cleanup()
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
// ===============================================================
|
|
631
|
-
section('16 audit C #2: per-session module instances — two sessions are fully isolated (v5 structure)')
|
|
632
|
-
{
|
|
633
|
-
// `holdFirstRun` defers the FIRST engine run inside the subprocess seam, so the test can prove
|
|
634
|
-
// isolation with a REAL interleaving: A is mid-flight while B runs to completion.
|
|
635
|
-
const h = await live({ installed: ['python3', 'python', 'octave'], holdFirstRun: true })
|
|
636
|
-
const setA = await h.callTool('vibe_v4_set', { mathEngines: ['python'], mathMode: 'typed+shell' }, h.ROOT)
|
|
637
|
-
const setB = await h.callTool('vibe_v4_set', { mathEngines: ['octave'], mathMode: 'typed' }, h.ROOT2)
|
|
638
|
-
assert(setA.ok === true && setB.ok === true, 'precondition: both sessions accepted their own math parameters')
|
|
639
|
-
const stA = await h.callTool('vibe_v4_status', {}, h.ROOT)
|
|
640
|
-
const stB = await h.callTool('vibe_v4_status', {}, h.ROOT2)
|
|
641
|
-
assert(/mathEngines=python(,|$)/.test(stA.params) && /mathEngines=octave(,|$)/.test(stB.params), 'precondition: the two sessions really hold different allow-lists (' + stA.params.match(/mathEngines=[^,]*/)[0] + ' vs ' + stB.params.match(/mathEngines=[^,]*/)[0] + ')')
|
|
642
|
-
// A starts and is HELD inside spawn ...
|
|
643
|
-
const pa = h.callTool('math_computation', { op: 'run', engine: 'auto', mode: 'code', code: 'print("A")\n' }, h.ROOT)
|
|
644
|
-
for (let i = 0; i < 400 && !h.state.releaseFirst; i++) await sleep(5)
|
|
645
|
-
assert(!!h.state.releaseFirst && h.state.heldRuns === 1, 'precondition: session A\'s engine run is held inside the subprocess seam')
|
|
646
|
-
// ... B completes WHILE A is still in flight (no cross-session blocking, no shared slot)
|
|
647
|
-
const rb = await h.callTool('math_computation', { op: 'run', engine: 'auto', mode: 'code', code: 'print("B")\n' }, h.ROOT2)
|
|
648
|
-
assert(rb.ok === true && rb.engine === 'octave', '★ B completed with its OWN engine (octave) while A was held (' + rb.engine + ')')
|
|
649
|
-
h.state.releaseFirst()
|
|
650
|
-
const ra = await pa
|
|
651
|
-
assert(ra.ok === true && ra.engine === 'python', '★ A resumed and still used its OWN engine (python), unaffected by B\'s in-flight call (' + ra.engine + ')')
|
|
652
|
-
assert(ra.receipt.dir !== rb.receipt.dir, '★ the two calls archived into DIFFERENT run dirs (each session its own evidence): ' + ra.receipt.dir + ' vs ' + rb.receipt.dir)
|
|
653
|
-
const recA = JSON.parse(readIf(join(h.projectRoot, ra.receipt.dir, 'receipt.json')))
|
|
654
|
-
const recB = JSON.parse(readIf(join(h.projectRoot, rb.receipt.dir, 'receipt.json')))
|
|
655
|
-
assert(JSON.stringify(recA.engine).indexOf('python') !== -1 && JSON.stringify(recB.engine).indexOf('octave') !== -1, '★ each receipt records its OWN engine (no "current session" slot to clobber)')
|
|
656
|
-
assert(JSON.stringify(recA.argv).indexOf('python') !== -1 && JSON.stringify(recB.argv).indexOf('octave') !== -1, '★ each receipt echoes its own argv')
|
|
657
|
-
const engineSpawns = h.state.spawns.filter(s => /python|octave/.test(String(s.argv[0])))
|
|
658
|
-
const norm = p => String(p).replace(/\\/g, '/').replace(/\/$/, '')
|
|
659
|
-
// round-7: EXECUTION spawns use the session's project root; DISCOVERY probes (version/licence) use
|
|
660
|
-
// a guaranteed-to-exist cwd instead, because on a brand-new session that root does not exist yet.
|
|
661
|
-
const versionSpawns = engineSpawns.filter(s => s.argv.indexOf('--version') !== -1)
|
|
662
|
-
const execSpawns = engineSpawns.filter(s => s.argv.indexOf('--version') === -1)
|
|
663
|
-
assert(execSpawns.length >= 2 && execSpawns.every(s => norm(s.cwd) === norm(h.projectRoot)), '★ every engine EXECUTION spawn went through its own session\'s project-root accessor (' + execSpawns.length + ' spawns, cwd=' + norm(execSpawns[0] && execSpawns[0].cwd) + ')')
|
|
664
|
-
assert(versionSpawns.length > 0 && versionSpawns.every(s => norm(s.cwd) !== norm(h.projectRoot)), '★ discovery/version probes use a guaranteed cwd, never the (possibly missing) project root (' + versionSpawns.length + ' probes)')
|
|
665
|
-
// Session B is in `typed` mode; session A must not inherit that policy either.
|
|
666
|
-
const cliA = await h.callTool('math_computation', { op: 'run', engine: 'cli', mode: 'code', code: '1\n', cli: { command: 'node', argv: [] } }, h.ROOT)
|
|
667
|
-
assert(cliA.code !== 'MATH_REFUSED' || !/typed/.test(String(cliA.message || '')), '★ session A is NOT in B\'s typed mode (cli refusal is not inherited)')
|
|
668
|
-
h.cleanup()
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
// ===============================================================
|
|
673
|
-
section('19 audit R2 lens-2: listDir wiring (per-project retention) + no-listDir skip')
|
|
674
|
-
{
|
|
675
|
-
const h = await live({ installed: ['python3', 'python'], listDirEntries: Array.from({ length: 201 }, (_, i) => ({ name: 'vibe-math-v4-proj-' + i, type: 'directory' })) })
|
|
676
|
-
const r = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
|
|
677
|
-
assert(r.ok === true, 'the run still succeeds with listDir wired')
|
|
678
|
-
assert(Array.isArray(r.warnings) && r.warnings.some(w => w.code === 'ARCHIVE_RETENTION_EXCEEDED'), '★ >200 run dirs under Computation/ ⇒ ARCHIVE_RETENTION_EXCEEDED (the per-project retention warning is reachable in v4)')
|
|
679
|
-
const rec = JSON.parse(readIf(join(h.projectRoot, r.receipt.dir, 'receipt.json')))
|
|
680
|
-
assert(Array.isArray(rec.warnings) && rec.warnings.some(w => w.code === 'ARCHIVE_RETENTION_EXCEEDED'), '★ …and it is recorded in the receipt')
|
|
681
|
-
h.cleanup()
|
|
682
|
-
const h2 = await live({ installed: ['python3', 'python'], noListDir: true })
|
|
683
|
-
const r2 = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(2)\n' })
|
|
684
|
-
assert(r2.ok === true && !(r2.warnings || []).some(w => w.code === 'ARCHIVE_RETENTION_EXCEEDED'), '★ with NO listDir the per-project check is skipped entirely (no warning)')
|
|
685
|
-
// The field must be ABSENT, not merely empty-returning: the module only calls it when present.
|
|
686
|
-
assert(h2.state.listDirCalls.length === 0, '★ with NO listDir the module never queries the host at all (0 listDir calls)')
|
|
687
|
-
assert(h.state.listDirCalls.length >= 1, 'with listDir wired the retention check does query the host (>=1 call)')
|
|
688
|
-
h2.cleanup()
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// ===============================================================
|
|
692
|
-
section('20 round-3: the archive DIRECTORIES really exist on disk (host creates missing parents)')
|
|
693
|
-
{
|
|
694
|
-
// The real host's text write creates missing parents (dsh-fs-local `writeFileAtomic` runs
|
|
695
|
-
// `mkdir(dirname, {recursive:true})`), so the module never has to mkdir. What this pins is the
|
|
696
|
-
// OBSERVABLE result: after a run the archive directory and its attempt chain exist as directories.
|
|
697
|
-
const h = await live({ installed: ['python3', 'python'] })
|
|
698
|
-
const r = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
|
|
699
|
-
assert(r.ok === true, 'the run succeeds')
|
|
700
|
-
const abs = (rel) => join(h.projectRoot, ...String(rel).split('/'))
|
|
701
|
-
// round-4 item 3: `abs('')` is the project root, which would satisfy a bare existsSync+isDirectory
|
|
702
|
-
// check - so pin the SHAPE first (a real archive path, and the same dir the receipt reference uses).
|
|
703
|
-
assert(r.attemptDir.indexOf('Computation/') !== -1, '★ attemptDir is a real archive path (contains Computation/)')
|
|
704
|
-
assert(r.attemptDir === r.receipt.dir, '★ attemptDir === receipt.dir (directory and receipt reference agree)')
|
|
705
|
-
assert(existsSync(abs(r.attemptDir)) && statSync(abs(r.attemptDir)).isDirectory(), '★ Computation/<runId>/ exists as a DIRECTORY after the run')
|
|
706
|
-
assert(existsSync(abs(r.scriptPath)) && statSync(abs(r.scriptPath)).isFile(), '★ the archived script exists on disk')
|
|
707
|
-
const r2 = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
|
|
708
|
-
assert(r2.attempt === 2 && existsSync(abs(r2.attemptDir)) && statSync(abs(r2.attemptDir)).isDirectory(), '★ the second attempt creates Computation/<runId>/attempts/2/ as a DIRECTORY (parents included)')
|
|
709
|
-
assert(existsSync(abs(r.attemptDir)), 'the first attempt directory is untouched')
|
|
710
|
-
h.cleanup()
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
// ===============================================================
|
|
716
|
-
section('21 round-6 A: substitution-honesty rule in BOTH persona blocks AND in the injected line')
|
|
717
|
-
{
|
|
718
|
-
const yml = readIf(PERSONA_YML)
|
|
719
|
-
const zh = MODULE.MATH_SUBSTITUTION_RULE_LINE
|
|
720
|
-
const en = MODULE.MATH_SUBSTITUTION_RULE_LINE_EN
|
|
721
|
-
assert(!!zh && !!en, 'the shared module exports MATH_SUBSTITUTION_RULE_LINE and _EN')
|
|
722
|
-
for (const [lang, line] of [['zh', zh], ['en', en]]) {
|
|
723
|
-
const hits = yml.split(line).length - 1
|
|
724
|
-
assert(hits === 2, '★ the ' + lang + ' substitution rule appears verbatim in BOTH persona blocks (found ' + hits + ')')
|
|
725
|
-
}
|
|
726
|
-
assert(/替代必须声明(诚实性)/.test(yml) && /Declare substitutions \(honesty\)/.test(yml), 'the rule is present in both languages in the persona')
|
|
727
|
-
const h = await live({ installed: ['python3', 'python'] })
|
|
728
|
-
const prompt = await h.prompts('normal', 'r-1')
|
|
729
|
-
assert(prompt.indexOf(String(zh).trim()) !== -1, '★ the injected availability line carries the ZH substitution rule')
|
|
730
|
-
assert(prompt.indexOf(String(en).trim()) !== -1, '★ the injected availability line carries the EN substitution rule')
|
|
731
|
-
h.cleanup()
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
// ===============================================================
|
|
735
|
-
console.log('')
|
|
736
|
-
console.log('passed=' + passed + ' failed=' + failed)
|
|
737
|
-
if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
|
|
738
|
-
console.log('ALL GREEN')
|
|
739
|
-
process.exit(0)
|
|
1
|
+
// ============================================================
|
|
2
|
+
// math_computation — v4 WIRING suite (P1)
|
|
3
|
+
//
|
|
4
|
+
// Scope: this preset's接线 only — parameter surface, the ONE registration, prompt injection and the
|
|
5
|
+
// host adapter (`spawn`/`resolveExecutable`/fs) driving the SHARED module. The module's own behaviour
|
|
6
|
+
// is covered by the integration owner's `tests/math-computation-shared.test.mjs`; here every engine is
|
|
7
|
+
// a FAKE driven through the plugin's injected `subprocess` seam, so nothing real is executed.
|
|
8
|
+
//
|
|
9
|
+
// Fake-engine seam (guards.md §3): `resolveExecutable` resolves the P1 candidate names to shim paths;
|
|
10
|
+
// `spawn` classifies each argv against `math-engines.js` (version / licence / package probe / run) and
|
|
11
|
+
// records the full argv, cwd and stdio caps, plus every `terminate()` call.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/math-computation-v4.test.mjs
|
|
14
|
+
// V4_PLUGIN=<path> points the suite at a MUTATED copy (used by the sensitivity probe).
|
|
15
|
+
//
|
|
16
|
+
// Mutants this suite must kill (each makes ≥1 assertion red):
|
|
17
|
+
// M1 `default-engines-dropped` — drop 'cli' from `MATH_ENGINE_ORDER` in `vibe-math-v4/math-engines.js`
|
|
18
|
+
// (the real source of the default engine list: v4's `DEFAULT_PARAMS.mathEngines` is only
|
|
19
|
+
// `MATH_PARAM_DEFAULTS.mathEngines.slice()`; the old text also named `defaultOn`, which does not
|
|
20
|
+
// exist anywhere in the tree — a phantom anchor, corrected per the anchor-hygiene rule): the
|
|
21
|
+
// "cli is ON by default" assertions (§8) go red. Measured with the corrected site: 2 named reds.
|
|
22
|
+
// M2 `typed-still-shell` — call mathAvailabilityLine without the mode (or force 'typed+shell'): §12
|
|
23
|
+
// finds the shell-fallback sentence in the typed prompt → red.
|
|
24
|
+
// M3 `timeout-no-kill` — return the timeout without calling terminate(): §5 asserts the terminate
|
|
25
|
+
// count, so dropping the kill is red (not merely a reported MATH_TIMEOUT).
|
|
26
|
+
// M4 `registration-dropped` — remove registerMathComputation (or register a different name): §1
|
|
27
|
+
// finds no `math_computation` tool and the description/schema parity checks fail.
|
|
28
|
+
// M5 `params-not-normalized` — normalizeParam returns the raw value for the six keys: §11 sees
|
|
29
|
+
// `mathMode=bogus` / `mathTimeoutMs=5` / `mathEngines='python,cli'` survive verbatim → red.
|
|
30
|
+
// ============================================================
|
|
31
|
+
import { mkdtempSync, existsSync, readFileSync, mkdirSync, writeFileSync, rmSync, statSync } from 'node:fs'
|
|
32
|
+
import { tmpdir } from 'node:os'
|
|
33
|
+
import { join, dirname, isAbsolute, basename, resolve as pathResolve } from 'node:path'
|
|
34
|
+
import { pathToFileURL, fileURLToPath } from 'node:url'
|
|
35
|
+
|
|
36
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
37
|
+
// V4_PERSONA_YML points the persona assertions at a MUTATED persona file (sensitivity probe).
|
|
38
|
+
const PERSONA_YML = process.env.V4_PERSONA_YML ? pathResolve(process.env.V4_PERSONA_YML) : join(HERE, '..', 'vibe-math-v4', 'agent.cordis.yml')
|
|
39
|
+
const PLUGIN = process.env.V4_PLUGIN
|
|
40
|
+
? pathToFileURL(isAbsolute(process.env.V4_PLUGIN) ? process.env.V4_PLUGIN : join(HERE, '..', process.env.V4_PLUGIN))
|
|
41
|
+
: pathToFileURL(join(HERE, '..', 'vibe-math-v4', 'vibe-math-v4.js'))
|
|
42
|
+
const PRESET_DIR = dirname(fileURLToPath(PLUGIN))
|
|
43
|
+
const MODULE = await import(pathToFileURL(join(PRESET_DIR, 'math-computation.js')).href)
|
|
44
|
+
const ENGINES_MOD = await import(pathToFileURL(join(PRESET_DIR, 'math-engines.js')).href)
|
|
45
|
+
const MATH_ENGINES = ENGINES_MOD.MATH_ENGINES
|
|
46
|
+
|
|
47
|
+
let passed = 0, failed = 0
|
|
48
|
+
const failures = []
|
|
49
|
+
const assert = (c, m) => { if (c) { passed++; console.log(' ok - ' + m) } else { failed++; failures.push(m); console.error(' FAIL - ' + m) } }
|
|
50
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
51
|
+
const section = t => console.log('\n[' + t + ']')
|
|
52
|
+
const promptOf = e => (e && e.blocks && e.blocks[0] && e.blocks[0].text) || ''
|
|
53
|
+
const readIf = p => (existsSync(p) ? readFileSync(p, 'utf8') : '')
|
|
54
|
+
|
|
55
|
+
// ===============================================================
|
|
56
|
+
// FAKE ENGINE SEAM + MOCK HOST
|
|
57
|
+
// ===============================================================
|
|
58
|
+
function makeSubprocess(state) {
|
|
59
|
+
const base = c => String(c).split(/[\\/]/).pop().replace(/\.(cmd|exe|sh|bat)$/i, '')
|
|
60
|
+
const executableFor = cmd => {
|
|
61
|
+
const b = base(cmd)
|
|
62
|
+
if (state.cliCommands.indexOf(String(cmd)) !== -1 || state.cliCommands.indexOf(b) !== -1) return 'X:/fake/bin/' + b
|
|
63
|
+
const known = []
|
|
64
|
+
for (const name of Object.keys(MATH_ENGINES)) { const cands = ENGINES_MOD.mathEngineCandidates(name); if (cands) for (const x of cands) known.push(x) }
|
|
65
|
+
if (known.indexOf(b) === -1) throw new Error('not found: ' + cmd)
|
|
66
|
+
if (state.installed.indexOf(b) === -1) throw new Error('not found: ' + cmd)
|
|
67
|
+
return 'X:/fake/bin/' + b
|
|
68
|
+
}
|
|
69
|
+
const engineOf = argv => {
|
|
70
|
+
const b = base(argv[0])
|
|
71
|
+
for (const name of Object.keys(MATH_ENGINES)) {
|
|
72
|
+
const cands = ENGINES_MOD.mathEngineCandidates(name)
|
|
73
|
+
if (cands && cands.indexOf(b) !== -1) return MATH_ENGINES[name]
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
const classify = argv => {
|
|
78
|
+
const d = engineOf(argv)
|
|
79
|
+
const rest = argv.slice(1).join(' ')
|
|
80
|
+
if (!d) return 'run'
|
|
81
|
+
if (d.versionArgv && rest === d.versionArgv.join(' ')) return 'version'
|
|
82
|
+
if (d.licenseProbe && rest === d.licenseProbe.argv.join(' ')) return 'licence'
|
|
83
|
+
if (d.probeCode && rest.indexOf(d.probeCode) !== -1) return 'packages'
|
|
84
|
+
return 'run'
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
async resolveExecutable(cmd) {
|
|
88
|
+
if (!state.subprocessAvailable) throw new Error('no subprocess')
|
|
89
|
+
return executableFor(cmd)
|
|
90
|
+
},
|
|
91
|
+
spawn(spec) {
|
|
92
|
+
const argv0 = String((spec.argv && spec.argv[0]) || '')
|
|
93
|
+
if (/powershell|cmd\.exe|\/bin\/sh|(^|\/)sh$/i.test(argv0)) {
|
|
94
|
+
state.shellCalls.push(String(spec.argv[spec.argv.length - 1] || ''))
|
|
95
|
+
const empty = ''
|
|
96
|
+
return {
|
|
97
|
+
done: Promise.resolve({ exitCode: 0, signal: null }),
|
|
98
|
+
collected: { stdout: { readFrom: () => ({ text: empty, nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: empty, nextOffset: 0, lossy: false }) } },
|
|
99
|
+
terminate() {},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
state.spawns.push({ argv: spec.argv.slice(), cwd: spec.cwd, graceMs: spec.graceMs, stdoutCap: spec.stdio && spec.stdio.stdout && spec.stdio.stdout.maxBytes })
|
|
103
|
+
const kind = classify(spec.argv)
|
|
104
|
+
const collect = (stdout, stderr) => ({
|
|
105
|
+
done: Promise.resolve({ exitCode: 0, signal: null }),
|
|
106
|
+
collected: {
|
|
107
|
+
stdout: { readFrom: () => ({ text: stdout, nextOffset: stdout.length, lossy: false }) },
|
|
108
|
+
stderr: { readFrom: () => ({ text: stderr, nextOffset: stderr.length, lossy: false }) },
|
|
109
|
+
},
|
|
110
|
+
terminate() {},
|
|
111
|
+
})
|
|
112
|
+
const record = { kind }
|
|
113
|
+
if (kind === 'version') return collect(base(spec.argv[0]) + ' ' + state.version + '\n', '')
|
|
114
|
+
if (kind === 'licence') return collect(state.licensed ? '1\n' : '0\n', '')
|
|
115
|
+
if (kind === 'packages') {
|
|
116
|
+
const joined = spec.argv.join(' ')
|
|
117
|
+
const pairs = []
|
|
118
|
+
for (const p of Object.keys(state.packages)) if (joined.indexOf(p) !== -1) pairs.push(p + ':' + (state.packages[p] ? 'ok' : 'missing'))
|
|
119
|
+
return collect(pairs.join('|') + '\n', '')
|
|
120
|
+
}
|
|
121
|
+
// run (also: the package-manager argv an approved install plan produces)
|
|
122
|
+
if (state.hang) {
|
|
123
|
+
return {
|
|
124
|
+
done: new Promise(() => {}),
|
|
125
|
+
collected: { stdout: { readFrom: () => ({ text: 'partial', nextOffset: 7, lossy: false }) }, stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) } },
|
|
126
|
+
terminate() { state.terminated.push(spec.argv.join(' ')) },
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (state.argError) {
|
|
130
|
+
return {
|
|
131
|
+
done: Promise.resolve({ exitCode: 2, signal: null }),
|
|
132
|
+
collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: state.argError, nextOffset: state.argError.length, lossy: false }) } },
|
|
133
|
+
terminate() {},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (state.exit !== 0) {
|
|
137
|
+
return {
|
|
138
|
+
done: Promise.resolve({ exitCode: state.exit, signal: null }),
|
|
139
|
+
collected: { stdout: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) }, stderr: { readFrom: () => ({ text: 'boom\n', nextOffset: 5, lossy: false }) } },
|
|
140
|
+
terminate() {},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const out = state.stdoutBytes > 0 ? 'x'.repeat(state.stdoutBytes) : 'ran-ok\n'
|
|
144
|
+
record.argv = spec.argv.slice()
|
|
145
|
+
state.runs.push(record)
|
|
146
|
+
// Defer/release hook (§16): the FIRST engine run is held INSIDE spawn until the test releases
|
|
147
|
+
// it, so a second session's call can be driven to completion while the first is in flight.
|
|
148
|
+
if (state.holdFirstRun) {
|
|
149
|
+
state.holdFirstRun = false
|
|
150
|
+
state.heldRuns++
|
|
151
|
+
let release
|
|
152
|
+
const gate = new Promise(r => { release = r })
|
|
153
|
+
state.releaseFirst = () => release()
|
|
154
|
+
return {
|
|
155
|
+
done: gate.then(() => ({ exitCode: 0, signal: null })),
|
|
156
|
+
collected: {
|
|
157
|
+
stdout: { readFrom: () => ({ text: out, nextOffset: out.length, lossy: false }) },
|
|
158
|
+
stderr: { readFrom: () => ({ text: '', nextOffset: 0, lossy: false }) },
|
|
159
|
+
},
|
|
160
|
+
terminate() {},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return collect(out, '')
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function makeHost(opts = {}) {
|
|
169
|
+
const WS = mkdtempSync(join(tmpdir(), 'vibe-v4-math-'))
|
|
170
|
+
const toolRegs = [], cmdRegs = [], spawns = [], followups = [], listeners = {}
|
|
171
|
+
const state = {
|
|
172
|
+
installed: (opts.installed === undefined ? ['python3', 'python'] : opts.installed).slice(),
|
|
173
|
+
cliCommands: (opts.cliCommands || ['node']).slice(),
|
|
174
|
+
packages: Object.assign({}, opts.packages || {}),
|
|
175
|
+
licensed: opts.licensed !== false,
|
|
176
|
+
version: opts.version || '1.2.3',
|
|
177
|
+
exit: opts.exit === undefined ? 0 : opts.exit,
|
|
178
|
+
hang: !!opts.hang,
|
|
179
|
+
stdoutBytes: opts.stdoutBytes || 0,
|
|
180
|
+
argError: opts.argError || null,
|
|
181
|
+
subprocessAvailable: opts.subprocessAvailable !== false,
|
|
182
|
+
resolveOnly: !!opts.resolveOnly,
|
|
183
|
+
// audit-R2 lens-2 §19: these three WERE being passed by the test but dropped here, so the
|
|
184
|
+
// fixture never reached `fs.listDir` and the retention assertion could not fire. A state
|
|
185
|
+
// whitelist silently swallowing a fixture field is exactly the "test that cannot fail" trap.
|
|
186
|
+
listDirEntries: opts.listDirEntries || null,
|
|
187
|
+
noListDir: !!opts.noListDir,
|
|
188
|
+
listDirCalls: [],
|
|
189
|
+
spawns: [], terminated: [], runs: [], shellCalls: [], holdFirstRun: !!opts.holdFirstRun, heldRuns: 0, releaseFirst: null,
|
|
190
|
+
}
|
|
191
|
+
const subprocess = makeSubprocess(state)
|
|
192
|
+
const subprocessSurface = state.resolveOnly ? { resolveExecutable: subprocess.resolveExecutable } : subprocess
|
|
193
|
+
const ROOT = { id: 'sess-A', session: { id: 'sess-A', header: { cwd: WS, parentSession: undefined } } }
|
|
194
|
+
// A SECOND root agent on the same ctx: v4 keys sessions by root agent id, so this is a second
|
|
195
|
+
// session with its OWN `params` (audit C #2 needs two sessions in one plugin instance).
|
|
196
|
+
const ROOT2 = { id: 'sess-B', session: { id: 'sess-B', header: { cwd: WS, parentSession: undefined } } }
|
|
197
|
+
const ctx = {
|
|
198
|
+
get(name) { return name === 'subprocess' && state.subprocessAvailable ? subprocessSurface : undefined },
|
|
199
|
+
on(e, fn) { (listeners[e] = listeners[e] || []).push(fn) },
|
|
200
|
+
effect(fn) { const d = fn(); return () => { if (typeof d === 'function') d() } },
|
|
201
|
+
logger: { info() {}, warn() {}, error() {} },
|
|
202
|
+
timeout(cb, ms) { const h = setTimeout(cb, ms); return () => clearTimeout(h) },
|
|
203
|
+
tools: { register(s) { toolRegs.push(s) } },
|
|
204
|
+
commands: { register(s) { cmdRegs.push(s) } },
|
|
205
|
+
subagents: {
|
|
206
|
+
list() { return ['spawn'] },
|
|
207
|
+
async startContinuable({ label, request }) { const id = 'c' + (spawns.length + 1); spawns.push({ label, request, childId: id }); return { childId: id } },
|
|
208
|
+
async sendMessage(parent, childId, blocks) { followups.push({ childId, blocks }) },
|
|
209
|
+
async interrupt() {},
|
|
210
|
+
},
|
|
211
|
+
agents: { roots() { return [] }, get(id) { return id === 'sess-A' ? ROOT : (id === 'sess-B' ? ROOT2 : undefined) } },
|
|
212
|
+
fs: {
|
|
213
|
+
async resolve(rel, o) { const b = (o && o.cwd) || WS; return { targetKey: (typeof rel === 'string' && isAbsolute(rel)) ? rel.replace(/\//g, '\\') : join(b, ...String(rel).split('/')) } },
|
|
214
|
+
async stat(t) { return existsSync(t.targetKey) ? { version: 'v1', type: 'file', size: 1 } : undefined },
|
|
215
|
+
async readText(t) { return readFileSync(t.targetKey, 'utf8') },
|
|
216
|
+
async writeText(t, c) { mkdirSync(dirname(t.targetKey), { recursive: true }); writeFileSync(t.targetKey, c, 'utf8') },
|
|
217
|
+
// `noListDir` removes the METHOD (not merely its result), so the plugin's conditional
|
|
218
|
+
// pass-through can be exercised: the module must then never be given a listDir at all.
|
|
219
|
+
...(opts.noListDir ? {} : { async listDir(t) { state.listDirCalls.push(t && t.targetKey); if (state.listDirEntries) return state.listDirEntries; return [] } }),
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
const projectRoot = join(WS, 'VibeMath', 'Projects', 'default')
|
|
223
|
+
const h = {
|
|
224
|
+
WS, ctx, state, ROOT, ROOT2, projectRoot, toolRegs, spawns, followups, cmdRegs, listeners,
|
|
225
|
+
async cmd(rawInput) { const c = cmdRegs.find(x => x.name === 'v4'); if (!c) throw new Error('no /v4'); return await c.handler({ agent: ROOT, rawInput }) },
|
|
226
|
+
async callTool(n, a, agent) { const s = toolRegs.find(x => x.name === n); if (!s) throw new Error('no tool ' + n); return JSON.parse(await s.execute(a || {}, { agent: agent || ROOT })) },
|
|
227
|
+
async math(a) { return await h.callTool('math_computation', a) },
|
|
228
|
+
async prompts(which, rId, arg) { return (await h.callTool('vibe_v4_prompts', Object.assign({ which, member: rId }, arg || {}))).text },
|
|
229
|
+
resAgent: cid => ({ id: cid, session: { id: cid, header: { cwd: WS, parentSession: 'sess-A' } } }),
|
|
230
|
+
ridOfChild(cid) { for (const sp of spawns) if (sp.childId === cid) return sp.label; return '' },
|
|
231
|
+
childOf(rId) { for (const sp of spawns) if (sp.label === rId) return sp.childId; return '' },
|
|
232
|
+
fireEnd(cid, reply) {
|
|
233
|
+
const blocks = reply === undefined ? [] : [{ type: 'text', text: '```json\n' + JSON.stringify(reply) + '\n```' }]
|
|
234
|
+
for (const fn of (listeners['subagent/end'] || [])) fn({ id: cid, runId: 'r', provider: 'spawn', local: true, stopReason: 'completed', lastAssistantMessage: blocks })
|
|
235
|
+
},
|
|
236
|
+
cleanup() { try { rmSync(WS, { recursive: true, force: true }) } catch (e) { /* best effort */ } },
|
|
237
|
+
}
|
|
238
|
+
return h
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function mount(opts) {
|
|
242
|
+
const h = makeHost(opts)
|
|
243
|
+
const mod = await import(PLUGIN.href + '?t=' + Date.now() + Math.random())
|
|
244
|
+
;(mod.default || mod).apply(h.ctx)
|
|
245
|
+
return h
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Start a run so a session exists, then park the heartbeat (these tests drive calls explicitly). */
|
|
249
|
+
async function live(opts) {
|
|
250
|
+
const h = await mount(opts)
|
|
251
|
+
const before = h.spawns.length
|
|
252
|
+
await h.callTool('vibe_v4_start', { problem: 'math_computation 接线验证', residentCount: 1 })
|
|
253
|
+
for (let i = 0; i < 60 && h.spawns.length < before + 1; i++) await sleep(10)
|
|
254
|
+
for (const sp of h.spawns.slice(before)) { h.fireEnd(sp.childId, { summary: '初始见解。', solved: false, contextPct: 10 }); await sleep(30) }
|
|
255
|
+
await h.callTool('vibe_v4_set', { activityTimeoutMs: 600000 })
|
|
256
|
+
for (let i = 0; i < 40; i++) { const s = await h.callTool('vibe_v4_status', {}); if (s.phase === 'active') break; await sleep(20) }
|
|
257
|
+
return h
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ===============================================================
|
|
261
|
+
section('1 the tool is registered ONCE, with the shared module\'s description/schema')
|
|
262
|
+
{
|
|
263
|
+
const h = await live()
|
|
264
|
+
const keys = ["name", "description", "parameters", "execute"]
|
|
265
|
+
const regs = h.toolRegs.filter(t => t.name === 'math_computation')
|
|
266
|
+
assert(regs.length === 1, '★ math_computation is registered exactly once (FREEZE §4: host.register is called once)')
|
|
267
|
+
assert(!!regs[0] && keys.every(k => k in regs[0]), 'the registration carries the {name,description,parameters,execute} shape')
|
|
268
|
+
assert(regs[0].description === MODULE.MATH_TOOL_DESCRIPTION, '★ the description is the module\'s MATH_TOOL_DESCRIPTION verbatim (no local re-spelling)')
|
|
269
|
+
assert(JSON.stringify(regs[0].parameters) === JSON.stringify(MODULE.MATH_TOOL_SCHEMA), '★ the parameters are the module\'s closed MATH_TOOL_SCHEMA verbatim')
|
|
270
|
+
assert(regs[0].parameters.properties.op.enum.join(',') === 'probe,run,receipt,install', 'the op set is the frozen four')
|
|
271
|
+
assert(regs[0].parameters.additionalProperties === false, 'the schema stays closed')
|
|
272
|
+
const names = h.toolRegs.map(t => t.name)
|
|
273
|
+
assert(names.indexOf('math_computation') !== -1 && names.filter(n => n === 'math_computation').length === 1, 'it appears once in the tool list (the tool-count snapshot +1)')
|
|
274
|
+
// param visibility: paramsKeys is Object.keys(params) — the six keys must be there.
|
|
275
|
+
const st = await h.callTool('vibe_v4_status', {})
|
|
276
|
+
for (const k of MODULE.MATH_PARAM_NAMES) assert(st.paramsKeys.indexOf(k) !== -1, '★ status().paramsKeys exposes ' + k)
|
|
277
|
+
// round-9 (F2): in a fresh workspace there is no paper state, so `paper.dir` is a PROJECTION - the
|
|
278
|
+
// response must say so (reading status touches no fs and creates no directory).
|
|
279
|
+
assert(!!st.paper, '* status.paper exists (the gate below must NOT silently skip its own assertions)')
|
|
280
|
+
if (st.paper) {
|
|
281
|
+
assert(st.paper.dirProjected === true && st.paper.dirSource === 'projection', '★ a fresh status labels paper.dir as a PROJECTION (not a real directory)')
|
|
282
|
+
assert(st.paper.readSideEffect === false, '★ the paper view declares it has no read side effects')
|
|
283
|
+
assert(!existsSync(join(h.projectRoot, 'Paper')), '★ merely reading status did NOT create Paper/')
|
|
284
|
+
}
|
|
285
|
+
// round-9 (P2/D4): the roster is single-sourced - `status` exposes a version that BUMPS whenever the
|
|
286
|
+
// roster really changes (mid-flight add/remove is possible: removeMember reconciles in-flight work,
|
|
287
|
+
// addMember has no verify/meeting gate), and the participant set is reported as a FROZEN snapshot.
|
|
288
|
+
assert(typeof st.rosterVersion === 'number', '* status.rosterVersion is a number (the gate below must NOT silently skip its own assertions)')
|
|
289
|
+
if (typeof st.rosterVersion === 'number') {
|
|
290
|
+
assert(st.rosterVersion >= 0 && 'frozenParticipants' in st, '★ status exposes rosterVersion + the frozen participant set')
|
|
291
|
+
const v0 = st.rosterVersion
|
|
292
|
+
const hire = await h.callTool('vibe_v4_add_member', { direction: 'P2 roster-version assertion' })
|
|
293
|
+
const st2 = await h.callTool('vibe_v4_status', {})
|
|
294
|
+
assert(st2.rosterVersion > v0, '★ hiring mid-flight bumps rosterVersion (' + v0 + ' -> ' + st2.rosterVersion + ')')
|
|
295
|
+
assert(st2.frozenParticipants === null || Array.isArray(st2.frozenParticipants), '★ with nothing frozen the field is explicitly null (not a stale set)')
|
|
296
|
+
if (hire && hire.ok && hire.id) {
|
|
297
|
+
await h.callTool('vibe_v4_remove_member', { id: hire.id })
|
|
298
|
+
const st3 = await h.callTool('vibe_v4_status', {})
|
|
299
|
+
assert(st3.rosterVersion > st2.rosterVersion, '★ firing mid-flight bumps rosterVersion too (' + st2.rosterVersion + ' -> ' + st3.rosterVersion + ')')
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
h.cleanup()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ===============================================================
|
|
306
|
+
section('2 op=probe: installed engines are detected, none ⇒ not available')
|
|
307
|
+
{
|
|
308
|
+
const h = await live({ installed: ['python3', 'python', 'octave'] })
|
|
309
|
+
const p = await h.math({ op: 'probe' })
|
|
310
|
+
assert(p.ok === true && p.op === 'probe' && p.available === true, 'probe reports ok/available when an engine resolves')
|
|
311
|
+
const py = p.engines.find(e => e.name === 'python')
|
|
312
|
+
assert(!!py && py.version === '1.2.3' && /python/.test(py.path), '★ the version probe is parsed (python 1.2.3) and the path reported')
|
|
313
|
+
assert(p.engines.some(e => e.name === 'octave'), 'every installed P1 engine is listed')
|
|
314
|
+
assert(!p.engines.some(e => e.name === 'cli'), 'cli is never auto-detected (it needs a caller command)')
|
|
315
|
+
h.cleanup()
|
|
316
|
+
const h2 = await live({ installed: [] })
|
|
317
|
+
const p2 = await h2.math({ op: 'probe' })
|
|
318
|
+
assert(p2.ok === false && p2.code === 'MATH_ENGINE_NOT_FOUND' && p2.next && p2.next.kind === 'user-install', '★ nothing installed ⇒ MATH_ENGINE_NOT_FOUND + next.kind=user-install')
|
|
319
|
+
assert(p2.next.perOs && Object.keys(p2.next.perOs).length >= 3 && Object.values(p2.next.perOs).every(v => !!v), '★ …with non-empty per-OS install commands')
|
|
320
|
+
h2.cleanup()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ===============================================================
|
|
324
|
+
section('3 op=run: argv assembly, cwd, receipt + argv echo, project file mode')
|
|
325
|
+
{
|
|
326
|
+
const h = await live({ installed: ['python3', 'python'] })
|
|
327
|
+
const r = await h.math({ op: 'run', engine: 'python', mode: 'expr', expr: '2**10' })
|
|
328
|
+
assert(r.ok === true && r.engine === 'python' && r.exit === 0, 'a fake python run returns ok/exit 0')
|
|
329
|
+
const sp = h.state.spawns[h.state.spawns.length - 1]
|
|
330
|
+
const norm = p => String(p).replace(/\\/g, '/').replace(/\/$/, '')
|
|
331
|
+
assert(norm(sp.cwd) === norm(h.projectRoot), '★ spawn cwd is the PROJECT root (' + JSON.stringify(sp.cwd) + ' vs ' + JSON.stringify(h.projectRoot) + ')')
|
|
332
|
+
const runArgv = h.state.runs[h.state.runs.length - 1].argv
|
|
333
|
+
assert(runArgv.length === 3 && runArgv[0] === 'X:/fake/bin/python3' && runArgv[1] === '-c' && runArgv[2] === '2**10', '★ mode=expr puts the expression in exactly ONE argv element (no shell quoting, no split)')
|
|
334
|
+
assert(JSON.stringify(r.argv) === JSON.stringify(runArgv), '★ the return shell echoes the ACTUAL executed argv')
|
|
335
|
+
assert(r.receipt && r.receipt.dir && existsSync(join(h.projectRoot, r.receipt.dir, 'receipt.json')), 'the receipt is archived under Computation/<id>/')
|
|
336
|
+
const rec = JSON.parse(readIf(join(h.projectRoot, r.receipt.dir, 'receipt.json')))
|
|
337
|
+
assert(JSON.stringify(rec.argv) === JSON.stringify(runArgv), '★ receipt.json.argv equals the executed argv element-by-element')
|
|
338
|
+
assert(rec.preset === 'vibe-math-v4', '★ the receipt carries designator=vibe-math-v4')
|
|
339
|
+
// mode=code: the script is materialised and its ABSOLUTE path is the last argv element
|
|
340
|
+
const r2 = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1+1)\n' })
|
|
341
|
+
assert(r2.ok === true, 'mode=code runs')
|
|
342
|
+
const argv2 = h.state.runs[h.state.runs.length - 1].argv
|
|
343
|
+
assert(argv2.length === 2 && /\.py$/.test(argv2[1]) && isAbsolute(argv2[1]), '★ mode=code passes the script as an absolute path (one element)')
|
|
344
|
+
assert(existsSync(argv2[1]) && /print\(1\+1\)/.test(readIf(argv2[1])), '★ the script file really exists on disk with the code (receipts are re-runnable)')
|
|
345
|
+
// mode=file: an in-project file is allowed
|
|
346
|
+
mkdirSync(join(h.projectRoot, 'tmp'), { recursive: true })
|
|
347
|
+
writeFileSync(join(h.projectRoot, 'tmp', 'calc.py'), 'print(2+2)\n')
|
|
348
|
+
const r3 = await h.math({ op: 'run', engine: 'python', mode: 'file', file: 'tmp/calc.py' })
|
|
349
|
+
assert(r3.ok === true, 'mode=file with an in-project script runs')
|
|
350
|
+
h.cleanup()
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ===============================================================
|
|
354
|
+
section('4 refusals: path escape, cli without command, cli+expr, policy, missing engine, no subprocess')
|
|
355
|
+
{
|
|
356
|
+
const h = await live({ installed: ['python3', 'python'] })
|
|
357
|
+
const esc = await h.math({ op: 'run', engine: 'python', mode: 'file', file: '../../../../etc/passwd' })
|
|
358
|
+
assert(esc.ok === false && esc.code === 'MATH_REFUSED', '★ a file outside the project root is refused (MATH_REFUSED)')
|
|
359
|
+
const esc2 = await h.math({ op: 'run', engine: 'python', mode: 'file', file: 'C:\\Windows\\evil.py' })
|
|
360
|
+
assert(esc2.ok === false && esc2.code === 'MATH_REFUSED', '★ an absolute path is refused too')
|
|
361
|
+
const noCmd = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: 'echo hi\n' })
|
|
362
|
+
assert(noCmd.ok === false && noCmd.code === 'MATH_REFUSED', '★ engine=cli without cli.command is refused')
|
|
363
|
+
const cliExpr = await h.math({ op: 'run', engine: 'cli', mode: 'expr', expr: '1+1', cli: { command: 'node' } })
|
|
364
|
+
assert(cliExpr.ok === false && cliExpr.code === 'MATH_REFUSED', '★ engine=cli + mode=expr is refused (cli has no eval template)')
|
|
365
|
+
const before = h.state.spawns.length
|
|
366
|
+
const notThere = await h.math({ op: 'run', engine: 'octave', mode: 'code', code: 'x=1\n' })
|
|
367
|
+
assert(notThere.ok === false && notThere.code === 'MATH_ENGINE_NOT_FOUND', '★ a missing engine reports MATH_ENGINE_NOT_FOUND')
|
|
368
|
+
// round-9 (real-engine): the guide is always per-OS; `command` itself is only offered when that
|
|
369
|
+
// OS's package manager is actually resolvable (a command that cannot run is misleading).
|
|
370
|
+
assert(notThere.next && notThere.next.kind === 'user-install' && notThere.next.perOs && notThere.next.perOs.windows, '★ …with per-OS user-install guidance (next.kind=user-install)')
|
|
371
|
+
assert(notThere.next.packageManagerAvailable === false ? notThere.next.command === '' : typeof notThere.next.command === 'string', '★ command ⇔ package-manager availability (never a command that cannot run)')
|
|
372
|
+
assert(h.state.spawns.length === before, 'a missing engine spawns nothing')
|
|
373
|
+
// (a) no subprocess service at all ⇒ the host's capability flag short-circuits to
|
|
374
|
+
// MATH_NO_SUBPROCESS (not a misleading ENGINE_NOT_FOUND + "install python" guide)
|
|
375
|
+
const h2 = await live({ installed: ['python3'], subprocessAvailable: false })
|
|
376
|
+
const noSub = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
|
|
377
|
+
assert(noSub.ok === false && noSub.code === 'MATH_NO_SUBPROCESS' && !!noSub.message, '★ a host with no subprocess service reports MATH_NO_SUBPROCESS (got ' + noSub.code + ')')
|
|
378
|
+
const probeNoSub = await h2.math({ op: 'probe' })
|
|
379
|
+
assert(probeNoSub.ok === false && probeNoSub.code === 'MATH_NO_SUBPROCESS', '★ …also for op=probe (the flag is checked before engine resolution)')
|
|
380
|
+
h2.cleanup()
|
|
381
|
+
// (b) a service that RESOLVES but cannot SPAWN ⇒ the module's MATH_NO_SUBPROCESS (the preset hands
|
|
382
|
+
// the module a null spawn result, which is its documented "host cannot execute" signal)
|
|
383
|
+
const h3 = await live({ installed: ['python3'], cliCommands: ['node'], resolveOnly: true })
|
|
384
|
+
const noSpawn = await h3.math({ op: 'run', engine: 'cli', mode: 'code', code: '1\n', cli: { command: 'node', argv: ['-e', '1'] } })
|
|
385
|
+
assert(noSpawn.ok === false && noSpawn.code === 'MATH_NO_SUBPROCESS', '★ a host that resolves but cannot spawn reports MATH_NO_SUBPROCESS (got ' + noSpawn.code + ')')
|
|
386
|
+
h3.cleanup(); h.cleanup()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ===============================================================
|
|
390
|
+
section('5 timeout ACTIVELY terminates; non-zero exit is not success')
|
|
391
|
+
{
|
|
392
|
+
const h = await live({ installed: ['python3', 'python'], hang: true })
|
|
393
|
+
await h.callTool('vibe_v4_set', { mathTimeoutMs: 1000 })
|
|
394
|
+
const t0 = Date.now()
|
|
395
|
+
const r = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'while True: pass\n' })
|
|
396
|
+
const dt = Date.now() - t0
|
|
397
|
+
assert(r.ok === false && r.code === 'MATH_TIMEOUT' && r.timedOut === true, '★ a hanging engine reports MATH_TIMEOUT/timedOut')
|
|
398
|
+
assert(h.state.terminated.length >= 1, '★ the timeout path really called handle.terminate() (an active kill, not a report)')
|
|
399
|
+
assert(dt >= 900 && dt < 5000, 'the budget honoured mathTimeoutMs (elapsed ' + dt + 'ms)')
|
|
400
|
+
h.cleanup()
|
|
401
|
+
const h2 = await live({ installed: ['python3', 'python'], exit: 3 })
|
|
402
|
+
const r2 = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'boom\n' })
|
|
403
|
+
assert(r2.ok === false && r2.code === 'MATH_NONZERO_EXIT' && r2.exit === 3, '★ a non-zero exit is MATH_NONZERO_EXIT{exit:3}, never ok:true')
|
|
404
|
+
assert(/boom/.test(String(r2.stderr || '')) || /boom/.test(JSON.stringify(r2)), 'the stderr tail is returned')
|
|
405
|
+
assert(r2.argv && r2.argv.length >= 2, '★ the failing call still echoes its argv (guards #19)')
|
|
406
|
+
h2.cleanup()
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ===============================================================
|
|
410
|
+
section('6 oversized output: complete file on disk, truncated reply')
|
|
411
|
+
{
|
|
412
|
+
const h = await live({ installed: ['python3', 'python'], stdoutBytes: 200 * 1024 })
|
|
413
|
+
const r = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print("x"*200000)\n' })
|
|
414
|
+
assert(r.ok === true, 'a large-output run still succeeds')
|
|
415
|
+
assert(r.truncated && r.truncated.stdout === true, '★ the REPLY is truncated (truncated.stdout:true)')
|
|
416
|
+
assert(Array.isArray(r.warnings) && r.warnings.some(w => w.code === 'OUTPUT_TRUNCATED'), '★ …and warned (OUTPUT_TRUNCATED)')
|
|
417
|
+
const outPath = join(h.projectRoot, r.receipt.dir, 'stdout.txt')
|
|
418
|
+
assert(existsSync(outPath) && readFileSync(outPath, 'utf8').length >= 200 * 1024, '★ the COMPLETE output is archived on disk (>64KB), so the receipt is re-checkable')
|
|
419
|
+
assert(h.state.spawns.some(s => s.stdoutCap >= 200 * 1024), '★ the preset passes the module\'s wide stdio cap through to the host (no silent 64KB collection loss)')
|
|
420
|
+
h.cleanup()
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ===============================================================
|
|
424
|
+
section('7 commercial engine: licence probe gates it, never installable')
|
|
425
|
+
{
|
|
426
|
+
const h = await live({ installed: ['matlab'], licensed: false })
|
|
427
|
+
const r = await h.math({ op: 'run', engine: 'matlab', mode: 'expr', expr: '1+1' })
|
|
428
|
+
assert(r.ok === false && r.code === 'MATH_ENGINE_LICENSE_REQUIRED', '★ an installed but unlicensed commercial engine reports LICENSE_REQUIRED')
|
|
429
|
+
assert(r.next && r.next.kind === 'vendor' && /mathworks/.test(JSON.stringify(r.next)), '★ …with the vendor link (never an install plan)')
|
|
430
|
+
const inst = await h.math({ op: 'install', engine: 'matlab', packages: ['x'], dryRun: true })
|
|
431
|
+
assert(inst.ok === false && inst.code === 'MATH_REFUSED', '★ op=install on a commercial engine is REFUSED (the preset never installs it)')
|
|
432
|
+
h.cleanup()
|
|
433
|
+
const h2 = await live({ installed: ['matlab'], licensed: true })
|
|
434
|
+
const ok = await h2.math({ op: 'run', engine: 'matlab', mode: 'expr', expr: '1+1' })
|
|
435
|
+
assert(ok.ok === true, 'with a valid licence the same call runs')
|
|
436
|
+
h2.cleanup()
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ===============================================================
|
|
440
|
+
section('8 cli engine: ON by default, and the two ways to disable it')
|
|
441
|
+
{
|
|
442
|
+
const h = await live({ installed: ['python3', 'python'], cliCommands: ['node'] })
|
|
443
|
+
const r = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: '1+1\n', cli: { command: 'node', argv: ['-e', 'console.log(1+1)'] } })
|
|
444
|
+
assert(r.ok === true, '★ engine=cli runs with the DEFAULT parameters')
|
|
445
|
+
const argv = h.state.runs[h.state.runs.length - 1].argv
|
|
446
|
+
// round-7 (finding 4 + live nuance): mode:'code' appends the archived script ONLY when the caller's
|
|
447
|
+
// argv has no program slot. This fixture passes `-e console.log(1+1)`, i.e. the caller's argv RUNS
|
|
448
|
+
// the code, so the script must NOT be appended (python/node would read it as an extra argument).
|
|
449
|
+
assert(argv.length === 3 && argv[1] === '-e' && argv[2] === 'console.log(1+1)', '★ cli argv is [<resolved command>, ...cli.argv] when the caller argv already runs code (nothing shell-wrapped)')
|
|
450
|
+
assert(r.cliScriptAppended === false, '★ the return shell reports cliScriptAppended=false for a caller-supplied program slot')
|
|
451
|
+
// …and with NO program slot the archived script IS appended (the "run this code" flow).
|
|
452
|
+
const r2 = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: '1+1\n', cli: { command: 'node', argv: [] } })
|
|
453
|
+
const argv2 = h.state.runs[h.state.runs.length - 1].argv
|
|
454
|
+
assert(r2.ok === true && r2.cliScriptAppended === true && /script\.txt$/.test(String(argv2[argv2.length - 1])), '★ …while an empty caller argv gets the archived script appended (' + JSON.stringify(argv2) + ')')
|
|
455
|
+
assert(String((r.engineInfo && r.engineInfo.name) || r.engine || '').indexOf('cli') === 0, '★ the engine is reported as cli:<command> (' + JSON.stringify(r.engineInfo && r.engineInfo.name) + ')')
|
|
456
|
+
assert(r.receipt && r.receipt.dir, '★ a cli run still leaves a receipt (the difference from the host shell)')
|
|
457
|
+
// (a) mathMode=typed refuses cli, with zero spawns
|
|
458
|
+
await h.callTool('vibe_v4_set', { mathMode: 'typed' })
|
|
459
|
+
const before = h.state.spawns.length
|
|
460
|
+
const typed = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: '1\n', cli: { command: 'node', argv: [] } })
|
|
461
|
+
assert(typed.ok === false && typed.code === 'MATH_REFUSED' && String(typed.next && typed.next.reason) === 'policy', '★ mathMode=typed refuses engine=cli with reason=policy')
|
|
462
|
+
assert(h.state.spawns.length === before, '★ …and spawns NOTHING (a policy refusal, not a late failure)')
|
|
463
|
+
await h.callTool('vibe_v4_set', { mathMode: 'typed+shell' })
|
|
464
|
+
// (b) removing cli from mathEngines refuses it too
|
|
465
|
+
await h.callTool('vibe_v4_set', { mathEngines: ['python', 'octave'] })
|
|
466
|
+
const before2 = h.state.spawns.length
|
|
467
|
+
const notAllowed = await h.math({ op: 'run', engine: 'cli', mode: 'code', code: '1\n', cli: { command: 'node', argv: [] } })
|
|
468
|
+
assert(notAllowed.ok === false && notAllowed.code === 'MATH_REFUSED', '★ removing cli from mathEngines refuses it as well')
|
|
469
|
+
assert(h.state.spawns.length === before2, '★ …also with zero spawns')
|
|
470
|
+
h.cleanup()
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ===============================================================
|
|
474
|
+
section('9 bad-argv is NOT collapsed into NONZERO_EXIT')
|
|
475
|
+
{
|
|
476
|
+
const h = await live({ installed: ['maple'], argError: "Error, unknown option '-q'\n" })
|
|
477
|
+
const r = await h.math({ op: 'run', engine: 'maple', mode: 'expr', expr: '1+1' })
|
|
478
|
+
assert(r.ok === false && r.code === 'MATH_ENGINE_BAD_ARGV', '★ a usage/option error exits as MATH_ENGINE_BAD_ARGV (not plain NONZERO_EXIT)')
|
|
479
|
+
assert(r.next && r.next.kind === 'engine-override', '★ next.kind=engine-override (an actionable hint)')
|
|
480
|
+
assert(JSON.stringify(r.next.argv) === JSON.stringify(h.state.spawns[h.state.spawns.length - 1].argv), '★ next.argv is the ACTUAL argv (what the receipt records)')
|
|
481
|
+
assert(/mathEngineOverride/.test(String(r.message || '')), '★ the message names mathEngineOverride')
|
|
482
|
+
h.cleanup()
|
|
483
|
+
const h2 = await live({ installed: ['python3', 'python'], exit: 3 })
|
|
484
|
+
const plain = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'x\n' })
|
|
485
|
+
assert(plain.code === 'MATH_NONZERO_EXIT', 'control: a plain non-zero exit stays MATH_NONZERO_EXIT (the branches are distinct)')
|
|
486
|
+
h2.cleanup()
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ===============================================================
|
|
490
|
+
section('10 package pre-check: found continues, missing stops before running')
|
|
491
|
+
{
|
|
492
|
+
const h = await live({ installed: ['python3', 'python'], packages: { numpy: true } })
|
|
493
|
+
const ok = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['numpy'] })
|
|
494
|
+
const pkgJson = JSON.stringify(ok.packages || {})
|
|
495
|
+
assert(ok.ok === true && pkgJson.indexOf('numpy') !== -1 && !/null|missing/.test(pkgJson), '★ a present package continues and is reported as usable (' + pkgJson + ')')
|
|
496
|
+
h.cleanup()
|
|
497
|
+
const h2 = await live({ installed: ['python3', 'python'], packages: { sympy: false } })
|
|
498
|
+
const before = h2.state.runs.length
|
|
499
|
+
const miss = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n', packages: ['sympy'] })
|
|
500
|
+
assert(miss.ok === false && miss.code === 'MATH_MISSING_PACKAGES', '★ a missing package reports MATH_MISSING_PACKAGES')
|
|
501
|
+
assert(!!miss.packages && !!miss.packages.found && miss.packages.found.sympy === null, '★ the missing list names the package (found.sympy === null)')
|
|
502
|
+
assert(miss.next && miss.next.kind === 'agent-install', '★ …and offers the agent-install path (plan → confirm)')
|
|
503
|
+
assert(h2.state.runs.length === before, '★ the script is NOT executed when a package is missing (guards #2)')
|
|
504
|
+
h2.cleanup()
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ===============================================================
|
|
508
|
+
section('11 op=install: plan → confirm, no auto-install, audit record')
|
|
509
|
+
{
|
|
510
|
+
const h = await live({ installed: ['python3', 'python'], packages: { sympy: false } })
|
|
511
|
+
const before = h.state.spawns.length
|
|
512
|
+
const plan = await h.math({ op: 'install', engine: 'python', packages: ['sympy'], dryRun: true })
|
|
513
|
+
assert(plan.ok === true && plan.plan && typeof plan.planToken === 'string' && plan.planToken.length >= 8, '★ op=install dryRun returns a plan + planToken')
|
|
514
|
+
assert(h.state.spawns.length === before, '★ the plan installs NOTHING (zero spawns)')
|
|
515
|
+
const wrong = await h.math({ op: 'install', engine: 'python', packages: ['sympy'], confirm: 'not-the-token' })
|
|
516
|
+
assert(wrong.ok === false && wrong.code === 'MATH_REFUSED', '★ a wrong token is refused')
|
|
517
|
+
assert(h.state.spawns.length === before, '★ …with zero spawns')
|
|
518
|
+
const done = await h.math({ op: 'install', engine: 'python', packages: ['sympy'], confirm: plan.planToken })
|
|
519
|
+
assert(done.ok === true, '★ the correct token executes the plan exactly once')
|
|
520
|
+
assert(h.state.spawns.length === before + 1, '★ exactly one package-manager invocation (no retry)')
|
|
521
|
+
const audit = readIf(join(h.projectRoot, 'Computation', 'installs', plan.planToken + '.json'))
|
|
522
|
+
assert(!!audit && /"scope"/.test(audit) && /"manager"/.test(audit), '★ the audit record is written under Computation/installs/<token>.json')
|
|
523
|
+
h.cleanup()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ===============================================================
|
|
527
|
+
section('12 parameters: six keys normalized, visible, and switched through both surfaces')
|
|
528
|
+
{
|
|
529
|
+
const h = await live({ installed: ['python3', 'python'], cliCommands: ['node'] })
|
|
530
|
+
const setSpec = h.toolRegs.find(t => t.name === 'vibe_v4_set')
|
|
531
|
+
for (const k of MODULE.MATH_PARAM_NAMES) assert(!!setSpec.parameters.properties[k], '★ vibe_v4_set advertises ' + k)
|
|
532
|
+
assert(setSpec.parameters.additionalProperties === false, 'the set schema stays closed')
|
|
533
|
+
assert(JSON.stringify(setSpec.parameters.properties.mathMode.enum) === JSON.stringify(['typed+shell', 'typed']), 'mathMode is a closed enum in the schema')
|
|
534
|
+
// string coercion through the SLASH-COMMAND surface (raw strings)
|
|
535
|
+
const c = await h.cmd('set mathMode=typed mathTimeoutMs=5 mathPackages=a,b mathEngines=python,cli')
|
|
536
|
+
assert(c.kind === 'success', '/v4 set accepted the six math keys')
|
|
537
|
+
const st = await h.callTool('vibe_v4_status', {})
|
|
538
|
+
assert(/mathMode=typed/.test(st.params), '★ mathMode=typed survived the string surface')
|
|
539
|
+
assert(/mathTimeoutMs=1000/.test(st.params), '★ mathTimeoutMs=5 was clamped by the shared normalizer (1000)')
|
|
540
|
+
assert(/mathPackages=a,b/.test(st.params), '★ the comma string became an array')
|
|
541
|
+
assert(/mathEngines=python,cli/.test(st.params), '★ mathEngines=python,cli became a filtered array')
|
|
542
|
+
// invalid values degrade to the module defaults (never to something stronger)
|
|
543
|
+
await h.callTool('vibe_v4_set', { mathMode: 'bogus', mathComputation: 'nonsense', mathInstallScope: 'root' })
|
|
544
|
+
const st2 = await h.callTool('vibe_v4_status', {})
|
|
545
|
+
assert(/mathMode=typed\+shell/.test(st2.params), '★ an unknown mathMode degrades to typed+shell')
|
|
546
|
+
assert(/mathComputation=auto/.test(st2.params) && /mathInstallScope=user/.test(st2.params), '★ unknown enums degrade to the defaults')
|
|
547
|
+
// mathComputation=off is a TRUE no-op: the tool refuses and no prompt line appears
|
|
548
|
+
await h.callTool('vibe_v4_set', { mathComputation: 'off' })
|
|
549
|
+
const off = await h.math({ op: 'probe' })
|
|
550
|
+
assert(off.ok === false && off.code === 'MATH_NOT_AVAILABLE', '★ mathComputation=off makes the tool a no-op (MATH_NOT_AVAILABLE)')
|
|
551
|
+
const offPrompt = await h.prompts('normal', 'r-1')
|
|
552
|
+
assert(!/先 probe 再 run/.test(offPrompt) && !/math_computation/.test(offPrompt), '★ …and injects NO prompt line at all')
|
|
553
|
+
h.cleanup()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ===============================================================
|
|
557
|
+
section('13 prompt surface: zh + en availability lines, typed drops the shell fallback')
|
|
558
|
+
{
|
|
559
|
+
const h = await live({ installed: ['python3', 'python'] })
|
|
560
|
+
await h.callTool('vibe_v4_set', { mathMode: 'typed+shell' })
|
|
561
|
+
const normal = await h.prompts('normal', 'r-1')
|
|
562
|
+
assert(/先 probe 再 run/.test(normal), '★ the Chinese availability line reaches the round prompt')
|
|
563
|
+
assert(/未经工具归档/.test(normal), '★ …including the shell-fallback rule (mathMode=typed+shell)')
|
|
564
|
+
assert(/Probe first, then run/.test(normal) && /not tool-archived/.test(normal), '★ the English variant is injected as well (v4 prompts are bilingual)')
|
|
565
|
+
assert(/归档即引用/.test(normal) && /不得把计算结果当成/.test(normal), '★ the rule lines include 归档即引用 and the "not a proof" rule')
|
|
566
|
+
const hb = await h.prompts('heartbeat', 'r-1')
|
|
567
|
+
assert(/先 probe 再 run/.test(hb), 'the heartbeat prompt also carries the line')
|
|
568
|
+
await h.callTool('vibe_v4_set', { mathMode: 'typed' })
|
|
569
|
+
const typed = await h.prompts('normal', 'r-1')
|
|
570
|
+
assert(/先 probe 再 run/.test(typed), 'the availability line is still there in typed mode')
|
|
571
|
+
assert(!/未经工具归档/.test(typed) && !/not tool-archived/.test(typed), '★ mathMode=typed injects NO shell-fallback sentence in either language (prompts.md §4-5)')
|
|
572
|
+
const typedHb = await h.prompts('heartbeat', 'r-1')
|
|
573
|
+
assert(!/未经工具归档/.test(typedHb) && !/not tool-archived/.test(typedHb), '★ …and the heartbeat prompt is consistent with the mode too')
|
|
574
|
+
h.cleanup()
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ===============================================================
|
|
578
|
+
section('14 persona: both text blocks carry the frozen tool line (byte-identical)')
|
|
579
|
+
{
|
|
580
|
+
const yml = readIf(PERSONA_YML)
|
|
581
|
+
const line = MODULE.MATH_PERSONA_TOOL_LINE
|
|
582
|
+
const hits = yml.split(line).length - 1
|
|
583
|
+
assert(hits === 2, '★ MATH_PERSONA_TOOL_LINE appears verbatim in BOTH persona blocks (found ' + hits + ')')
|
|
584
|
+
const first = yml.indexOf('- math_computation {op:')
|
|
585
|
+
assert(first !== -1 && yml.indexOf('- math_computation {op:', first + 1) !== -1, 'the two occurrences are in the two blocks (prefix + text)')
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ===============================================================
|
|
589
|
+
section('15 P2a: scriptPath/scriptHash, the archive→edit→re-run workflow, and the persona rule')
|
|
590
|
+
{
|
|
591
|
+
const h = await live({ installed: ['python3', 'python'] })
|
|
592
|
+
// ① mode=code: the archived script original + its hash are in the return AND the receipt
|
|
593
|
+
const r1 = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(41+1)\n' })
|
|
594
|
+
assert(r1.ok === true && typeof r1.scriptPath === 'string' && r1.scriptPath.length > 0, '★ mode=code returns scriptPath (the archived script original)')
|
|
595
|
+
assert(/^[0-9a-f]{64}$/.test(String(r1.scriptHash || '')), '★ …and scriptHash (sha256 of the archived code)')
|
|
596
|
+
const rec1rel = join(h.projectRoot, r1.receipt.dir, 'receipt.json')
|
|
597
|
+
const rec1 = JSON.parse(readIf(rec1rel))
|
|
598
|
+
assert(rec1.scriptPath === r1.scriptPath && rec1.scriptHash === r1.scriptHash, '★ the receipt carries the same scriptPath/scriptHash (re-runnable evidence)')
|
|
599
|
+
assert(rec1.attempt === 1 && rec1.attemptDir === r1.receipt.dir, '★ attempt 1 archives into Computation/<id>/ (attemptDir = the run dir)')
|
|
600
|
+
// ② mode=file: edit the SAME source file and re-run ⇒ a NEW attempt + an explicit warning
|
|
601
|
+
mkdirSync(join(h.projectRoot, 'tmp'), { recursive: true })
|
|
602
|
+
const src = join(h.projectRoot, 'tmp', 'workflow.py')
|
|
603
|
+
writeFileSync(src, 'print(1)\n')
|
|
604
|
+
const f1 = await h.math({ op: 'run', engine: 'python', mode: 'file', file: 'tmp/workflow.py' })
|
|
605
|
+
assert(f1.ok === true && f1.attempt === 1, 'precondition: the first mode=file run is attempt 1')
|
|
606
|
+
const firstDir = f1.receipt.dir, firstHash = f1.scriptHash
|
|
607
|
+
writeFileSync(src, 'print(2)\n') // the agent edits the archived/original code
|
|
608
|
+
const f2 = await h.math({ op: 'run', engine: 'python', mode: 'file', file: 'tmp/workflow.py' })
|
|
609
|
+
assert(f2.ok === true, 'the edited script re-runs')
|
|
610
|
+
assert(f2.receipt.dir !== firstDir && f2.attempt === 2 && /attempts\/2$/.test(String(f2.receipt.dir)), '★ the re-run is exactly attempt 2 in a NEW directory (append-only: attempts/<n>), never an overwrite (' + f2.receipt.dir + ')')
|
|
611
|
+
assert(f2.scriptChanged === true, '★ scriptChanged:true — the receipt for the current code differs from the previous one')
|
|
612
|
+
assert(!!f2.previousReceipt && String(f2.previousReceipt.scriptHash) === String(firstHash), '★ the RETURN SHELL also carries previousReceipt{runId,attempt,scriptHash} (not only the receipt file)')
|
|
613
|
+
assert(Array.isArray(f2.warnings) && f2.warnings.some(w => w.code === 'SCRIPT_CHANGED_SINCE_LAST_RECEIPT'), '★ warnings carry SCRIPT_CHANGED_SINCE_LAST_RECEIPT')
|
|
614
|
+
assert(String(f2.scriptHash) !== String(firstHash), '★ the new attempt records the NEW scriptHash')
|
|
615
|
+
const rec2 = JSON.parse(readIf(join(h.projectRoot, f2.receipt.dir, 'receipt.json')))
|
|
616
|
+
assert(rec2.previousReceipt && String(rec2.previousReceipt.scriptHash) === String(firstHash) && rec2.previousReceipt.attempt === 1 && !!rec2.previousReceipt.runId, '★ the attempt-2 receipt records previousReceipt{runId,attempt,scriptHash} pointing at attempt 1 (the old evidence is explicitly superseded)')
|
|
617
|
+
assert(existsSync(join(h.projectRoot, firstDir, 'receipt.json')) && existsSync(join(h.projectRoot, f2.receipt.dir, 'receipt.json')), '★ BOTH receipts stay on disk (the old one is not deleted or rewritten)')
|
|
618
|
+
// ③ the persona rule is in BOTH blocks, in both languages
|
|
619
|
+
const yml = readIf(PERSONA_YML)
|
|
620
|
+
for (const [name, line] of [['zh', MODULE.MATH_ARCHIVE_WORKFLOW_LINE], ['en', MODULE.MATH_ARCHIVE_WORKFLOW_LINE_EN]]) {
|
|
621
|
+
const hits = yml.split(line).length - 1
|
|
622
|
+
assert(hits === 2, '★ the ' + name + ' archive-workflow rule appears verbatim in BOTH persona blocks (found ' + hits + ')')
|
|
623
|
+
}
|
|
624
|
+
// …and the dynamic availability line carries it too (the module appends it)
|
|
625
|
+
const prompt = await h.prompts('normal', 'r-1')
|
|
626
|
+
assert(prompt.indexOf(MODULE.MATH_ARCHIVE_WORKFLOW_LINE.trim()) !== -1, '★ the injected availability line carries the archive-workflow rule as well')
|
|
627
|
+
h.cleanup()
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ===============================================================
|
|
631
|
+
section('16 audit C #2: per-session module instances — two sessions are fully isolated (v5 structure)')
|
|
632
|
+
{
|
|
633
|
+
// `holdFirstRun` defers the FIRST engine run inside the subprocess seam, so the test can prove
|
|
634
|
+
// isolation with a REAL interleaving: A is mid-flight while B runs to completion.
|
|
635
|
+
const h = await live({ installed: ['python3', 'python', 'octave'], holdFirstRun: true })
|
|
636
|
+
const setA = await h.callTool('vibe_v4_set', { mathEngines: ['python'], mathMode: 'typed+shell' }, h.ROOT)
|
|
637
|
+
const setB = await h.callTool('vibe_v4_set', { mathEngines: ['octave'], mathMode: 'typed' }, h.ROOT2)
|
|
638
|
+
assert(setA.ok === true && setB.ok === true, 'precondition: both sessions accepted their own math parameters')
|
|
639
|
+
const stA = await h.callTool('vibe_v4_status', {}, h.ROOT)
|
|
640
|
+
const stB = await h.callTool('vibe_v4_status', {}, h.ROOT2)
|
|
641
|
+
assert(/mathEngines=python(,|$)/.test(stA.params) && /mathEngines=octave(,|$)/.test(stB.params), 'precondition: the two sessions really hold different allow-lists (' + stA.params.match(/mathEngines=[^,]*/)[0] + ' vs ' + stB.params.match(/mathEngines=[^,]*/)[0] + ')')
|
|
642
|
+
// A starts and is HELD inside spawn ...
|
|
643
|
+
const pa = h.callTool('math_computation', { op: 'run', engine: 'auto', mode: 'code', code: 'print("A")\n' }, h.ROOT)
|
|
644
|
+
for (let i = 0; i < 400 && !h.state.releaseFirst; i++) await sleep(5)
|
|
645
|
+
assert(!!h.state.releaseFirst && h.state.heldRuns === 1, 'precondition: session A\'s engine run is held inside the subprocess seam')
|
|
646
|
+
// ... B completes WHILE A is still in flight (no cross-session blocking, no shared slot)
|
|
647
|
+
const rb = await h.callTool('math_computation', { op: 'run', engine: 'auto', mode: 'code', code: 'print("B")\n' }, h.ROOT2)
|
|
648
|
+
assert(rb.ok === true && rb.engine === 'octave', '★ B completed with its OWN engine (octave) while A was held (' + rb.engine + ')')
|
|
649
|
+
h.state.releaseFirst()
|
|
650
|
+
const ra = await pa
|
|
651
|
+
assert(ra.ok === true && ra.engine === 'python', '★ A resumed and still used its OWN engine (python), unaffected by B\'s in-flight call (' + ra.engine + ')')
|
|
652
|
+
assert(ra.receipt.dir !== rb.receipt.dir, '★ the two calls archived into DIFFERENT run dirs (each session its own evidence): ' + ra.receipt.dir + ' vs ' + rb.receipt.dir)
|
|
653
|
+
const recA = JSON.parse(readIf(join(h.projectRoot, ra.receipt.dir, 'receipt.json')))
|
|
654
|
+
const recB = JSON.parse(readIf(join(h.projectRoot, rb.receipt.dir, 'receipt.json')))
|
|
655
|
+
assert(JSON.stringify(recA.engine).indexOf('python') !== -1 && JSON.stringify(recB.engine).indexOf('octave') !== -1, '★ each receipt records its OWN engine (no "current session" slot to clobber)')
|
|
656
|
+
assert(JSON.stringify(recA.argv).indexOf('python') !== -1 && JSON.stringify(recB.argv).indexOf('octave') !== -1, '★ each receipt echoes its own argv')
|
|
657
|
+
const engineSpawns = h.state.spawns.filter(s => /python|octave/.test(String(s.argv[0])))
|
|
658
|
+
const norm = p => String(p).replace(/\\/g, '/').replace(/\/$/, '')
|
|
659
|
+
// round-7: EXECUTION spawns use the session's project root; DISCOVERY probes (version/licence) use
|
|
660
|
+
// a guaranteed-to-exist cwd instead, because on a brand-new session that root does not exist yet.
|
|
661
|
+
const versionSpawns = engineSpawns.filter(s => s.argv.indexOf('--version') !== -1)
|
|
662
|
+
const execSpawns = engineSpawns.filter(s => s.argv.indexOf('--version') === -1)
|
|
663
|
+
assert(execSpawns.length >= 2 && execSpawns.every(s => norm(s.cwd) === norm(h.projectRoot)), '★ every engine EXECUTION spawn went through its own session\'s project-root accessor (' + execSpawns.length + ' spawns, cwd=' + norm(execSpawns[0] && execSpawns[0].cwd) + ')')
|
|
664
|
+
assert(versionSpawns.length > 0 && versionSpawns.every(s => norm(s.cwd) !== norm(h.projectRoot)), '★ discovery/version probes use a guaranteed cwd, never the (possibly missing) project root (' + versionSpawns.length + ' probes)')
|
|
665
|
+
// Session B is in `typed` mode; session A must not inherit that policy either.
|
|
666
|
+
const cliA = await h.callTool('math_computation', { op: 'run', engine: 'cli', mode: 'code', code: '1\n', cli: { command: 'node', argv: [] } }, h.ROOT)
|
|
667
|
+
assert(cliA.code !== 'MATH_REFUSED' || !/typed/.test(String(cliA.message || '')), '★ session A is NOT in B\'s typed mode (cli refusal is not inherited)')
|
|
668
|
+
h.cleanup()
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
// ===============================================================
|
|
673
|
+
section('19 audit R2 lens-2: listDir wiring (per-project retention) + no-listDir skip')
|
|
674
|
+
{
|
|
675
|
+
const h = await live({ installed: ['python3', 'python'], listDirEntries: Array.from({ length: 201 }, (_, i) => ({ name: 'vibe-math-v4-proj-' + i, type: 'directory' })) })
|
|
676
|
+
const r = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
|
|
677
|
+
assert(r.ok === true, 'the run still succeeds with listDir wired')
|
|
678
|
+
assert(Array.isArray(r.warnings) && r.warnings.some(w => w.code === 'ARCHIVE_RETENTION_EXCEEDED'), '★ >200 run dirs under Computation/ ⇒ ARCHIVE_RETENTION_EXCEEDED (the per-project retention warning is reachable in v4)')
|
|
679
|
+
const rec = JSON.parse(readIf(join(h.projectRoot, r.receipt.dir, 'receipt.json')))
|
|
680
|
+
assert(Array.isArray(rec.warnings) && rec.warnings.some(w => w.code === 'ARCHIVE_RETENTION_EXCEEDED'), '★ …and it is recorded in the receipt')
|
|
681
|
+
h.cleanup()
|
|
682
|
+
const h2 = await live({ installed: ['python3', 'python'], noListDir: true })
|
|
683
|
+
const r2 = await h2.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(2)\n' })
|
|
684
|
+
assert(r2.ok === true && !(r2.warnings || []).some(w => w.code === 'ARCHIVE_RETENTION_EXCEEDED'), '★ with NO listDir the per-project check is skipped entirely (no warning)')
|
|
685
|
+
// The field must be ABSENT, not merely empty-returning: the module only calls it when present.
|
|
686
|
+
assert(h2.state.listDirCalls.length === 0, '★ with NO listDir the module never queries the host at all (0 listDir calls)')
|
|
687
|
+
assert(h.state.listDirCalls.length >= 1, 'with listDir wired the retention check does query the host (>=1 call)')
|
|
688
|
+
h2.cleanup()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ===============================================================
|
|
692
|
+
section('20 round-3: the archive DIRECTORIES really exist on disk (host creates missing parents)')
|
|
693
|
+
{
|
|
694
|
+
// The real host's text write creates missing parents (dsh-fs-local `writeFileAtomic` runs
|
|
695
|
+
// `mkdir(dirname, {recursive:true})`), so the module never has to mkdir. What this pins is the
|
|
696
|
+
// OBSERVABLE result: after a run the archive directory and its attempt chain exist as directories.
|
|
697
|
+
const h = await live({ installed: ['python3', 'python'] })
|
|
698
|
+
const r = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
|
|
699
|
+
assert(r.ok === true, 'the run succeeds')
|
|
700
|
+
const abs = (rel) => join(h.projectRoot, ...String(rel).split('/'))
|
|
701
|
+
// round-4 item 3: `abs('')` is the project root, which would satisfy a bare existsSync+isDirectory
|
|
702
|
+
// check - so pin the SHAPE first (a real archive path, and the same dir the receipt reference uses).
|
|
703
|
+
assert(r.attemptDir.indexOf('Computation/') !== -1, '★ attemptDir is a real archive path (contains Computation/)')
|
|
704
|
+
assert(r.attemptDir === r.receipt.dir, '★ attemptDir === receipt.dir (directory and receipt reference agree)')
|
|
705
|
+
assert(existsSync(abs(r.attemptDir)) && statSync(abs(r.attemptDir)).isDirectory(), '★ Computation/<runId>/ exists as a DIRECTORY after the run')
|
|
706
|
+
assert(existsSync(abs(r.scriptPath)) && statSync(abs(r.scriptPath)).isFile(), '★ the archived script exists on disk')
|
|
707
|
+
const r2 = await h.math({ op: 'run', engine: 'python', mode: 'code', code: 'print(1)\n' })
|
|
708
|
+
assert(r2.attempt === 2 && existsSync(abs(r2.attemptDir)) && statSync(abs(r2.attemptDir)).isDirectory(), '★ the second attempt creates Computation/<runId>/attempts/2/ as a DIRECTORY (parents included)')
|
|
709
|
+
assert(existsSync(abs(r.attemptDir)), 'the first attempt directory is untouched')
|
|
710
|
+
h.cleanup()
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
// ===============================================================
|
|
716
|
+
section('21 round-6 A: substitution-honesty rule in BOTH persona blocks AND in the injected line')
|
|
717
|
+
{
|
|
718
|
+
const yml = readIf(PERSONA_YML)
|
|
719
|
+
const zh = MODULE.MATH_SUBSTITUTION_RULE_LINE
|
|
720
|
+
const en = MODULE.MATH_SUBSTITUTION_RULE_LINE_EN
|
|
721
|
+
assert(!!zh && !!en, 'the shared module exports MATH_SUBSTITUTION_RULE_LINE and _EN')
|
|
722
|
+
for (const [lang, line] of [['zh', zh], ['en', en]]) {
|
|
723
|
+
const hits = yml.split(line).length - 1
|
|
724
|
+
assert(hits === 2, '★ the ' + lang + ' substitution rule appears verbatim in BOTH persona blocks (found ' + hits + ')')
|
|
725
|
+
}
|
|
726
|
+
assert(/替代必须声明(诚实性)/.test(yml) && /Declare substitutions \(honesty\)/.test(yml), 'the rule is present in both languages in the persona')
|
|
727
|
+
const h = await live({ installed: ['python3', 'python'] })
|
|
728
|
+
const prompt = await h.prompts('normal', 'r-1')
|
|
729
|
+
assert(prompt.indexOf(String(zh).trim()) !== -1, '★ the injected availability line carries the ZH substitution rule')
|
|
730
|
+
assert(prompt.indexOf(String(en).trim()) !== -1, '★ the injected availability line carries the EN substitution rule')
|
|
731
|
+
h.cleanup()
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// ===============================================================
|
|
735
|
+
console.log('')
|
|
736
|
+
console.log('passed=' + passed + ' failed=' + failed)
|
|
737
|
+
if (failed) { console.error('FAILURES:'); for (const f of failures) console.error(' - ' + f); process.exit(1) }
|
|
738
|
+
console.log('ALL GREEN')
|
|
739
|
+
process.exit(0)
|