kilo-loop/test/orchestrator-guard.test.mjs
2026-09-13 22:38:01 +03:00

117 lines
3.7 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { Orchestrator } from '../src/orchestrator.mjs'
const sink = { write: () => true }
function makeProject() {
const project = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-guard-orch-'))
fs.mkdirSync(path.join(project, '.kilocode-loop'), { recursive: true })
fs.writeFileSync(path.join(project, 'goal.json'), JSON.stringify({ title: 'Guard goal', stages: [{ id: 's1', title: 'Do work' }] }))
return project
}
function makeConfig(project, overrides = {}) {
return {
project,
goal: 'goal.json',
goalText: '',
iterations: 3,
agent: 'code-design',
model: '',
variant: '',
auto: true,
sharedContext: false,
hitl: 'off',
confirm: false,
port: 7997,
host: '127.0.0.1',
dryRun: true,
keepOpen: false,
quiet: true,
color: false,
promptExtra: '',
maxIterationMinutes: 0,
contextWarnTokens: 150000,
verify: '',
verifyTimeoutMinutes: 30,
maxCost: 0,
maxTokens: 0,
maxStaleIterations: 0,
checkpoint: false,
guard: true,
guardStop: false,
maxGuardBlocks: 3,
redact: true,
review: false,
reviewAgent: '',
autoFreshTokens: 0,
notify: '',
syncGoal: true,
...overrides,
}
}
/**
* Runner that emits a destructive bash tool_use only on the requested iteration,
* so the loop's runtime guard aborts that session but (by default) keeps going.
*/
function guardOnceRunner(guardIterations = [1]) {
return async ({ prompt, onEvent, signal }) => {
const iteration = Number(prompt.match(/iteration (\d+)\//)?.[1] || 1)
if (guardIterations.includes(iteration)) {
onEvent({ type: 'tool_use', part: { tool: 'bash', state: { input: { command: 'cd /repo && rm -rf src' } } } })
}
return {
sessionID: `ses_iter_${iteration}`,
exitCode: signal.aborted ? 130 : 0,
error: null,
texts: [],
reasoning: [],
toolCalls: [],
stderr: '',
durationMs: 5,
interrupted: signal.aborted,
}
}
}
test('a guard hit aborts the session but the run continues to the next iteration', async () => {
const project = makeProject()
const final = await new Orchestrator(makeConfig(project, { runner: guardOnceRunner([1]), iterations: 2, runId: 'guard-continue' }), { out: sink }).run()
assert.notEqual(final.status, 'stopped-guard')
assert.equal(final.stopReason, null)
assert.equal(final.iterations.length, 2)
assert.equal(final.iterations[0].guardViolation.id, 'rm-recursive-force')
// The safety feedback must reach the next iteration's prompt.
const prompt2 = fs.readFileSync(path.join(project, '.kilocode-loop', 'runs', 'guard-continue', 'iterations', '02-prompt.md'), 'utf8')
assert.match(prompt2, /Loop safety feedback/)
assert.match(prompt2, /rm-recursive-force/)
})
test('--guard-stop restores the hard stop on the first hit', async () => {
const project = makeProject()
const final = await new Orchestrator(makeConfig(project, { runner: guardOnceRunner(), guardStop: true, runId: 'guard-stop' }), { out: sink }).run()
assert.equal(final.status, 'stopped-guard')
assert.equal(final.stopReason, 'guard')
assert.equal(final.iterations.length, 1)
})
test('a persistently blocked agent stops after maxGuardBlocks iterations', async () => {
const project = makeProject()
const final = await new Orchestrator(
makeConfig(project, { runner: guardOnceRunner([1, 2, 3]), maxGuardBlocks: 2, iterations: 5, runId: 'guard-limit' }),
{ out: sink },
).run()
assert.equal(final.status, 'stopped-guard')
assert.equal(final.stopReason, 'guard')
assert.equal(final.iterations.length, 2)
})