190 lines
9.5 KiB
JavaScript
190 lines
9.5 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 { Readable } from 'node:stream'
|
|
import * as plugin from '../index.js'
|
|
|
|
// Settings persist to <DSH_PROFILE_DIR>/omp-advisor/sessions.json; keep tests away from the real profile.
|
|
const PROFILE = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-advisor-test-'))
|
|
process.env.DSH_PROFILE_DIR = PROFILE
|
|
const SETTINGS = path.join(PROFILE, 'omp-advisor', 'sessions.json')
|
|
test.after(() => fs.rmSync(PROFILE, { recursive: true, force: true }))
|
|
|
|
/**
|
|
* @param config plugin config overrides
|
|
* @param opts { id: session id (default 'session-a'), on: pre-enable this session (default true) }
|
|
*/
|
|
function harness(replies, config = {}, opts = {}) {
|
|
const sessionId = opts.id ?? 'session-a'
|
|
fs.rmSync(SETTINGS, { force: true }) // live settings load in apply(): never leak between tests
|
|
if (opts.on !== false) {
|
|
fs.mkdirSync(path.dirname(SETTINGS), { recursive: true })
|
|
fs.writeFileSync(SETTINGS, JSON.stringify({ [sessionId]: { enabled: true, at: 1 } }))
|
|
}
|
|
const listeners = new Map()
|
|
const on = (n, f) => { listeners.set(n, [...(listeners.get(n) ?? []), f]); return () => listeners.set(n, listeners.get(n).filter((x) => x !== f)) }
|
|
const prompts = []
|
|
const llm = {
|
|
async *stream(opts) {
|
|
prompts.push(opts)
|
|
const r = replies.shift() ?? '{"verdict":"ok","message":""}'
|
|
yield { type: 'text-delta', text: r }
|
|
yield { type: 'finish', reason: { kind: 'stop' } }
|
|
},
|
|
}
|
|
const events = []
|
|
const session = {
|
|
header: { cwd: '/w' },
|
|
requestHeader: () => ({ config: { provider: 'omni', model: 'main-model' } }),
|
|
surface: { get nodes() { return events.map((_, i) => i) } },
|
|
eventAt: (i) => events[i],
|
|
deriveEventMessage: (e) => e.msg,
|
|
}
|
|
const steered = []
|
|
const agent = { id: sessionId, session: { ...session, header: { ...session.header, id: sessionId } }, ctx: { on }, steer: (m) => steered.push(m) }
|
|
const routes = new Map()
|
|
const webServer = { register: (r) => { routes.set(r.path, r.handler); return () => routes.delete(r.path) } }
|
|
const connection = { requestRejection: (req) => (req.headers?.['x-reject'] ? 401 : undefined) }
|
|
const ctx = {
|
|
agents: { list: () => [] }, llm, on, effect(fn) { return typeof fn === 'function' ? fn() : undefined },
|
|
inject: (_deps, fn) => fn({ webServer, connection, effect: (f) => f() }),
|
|
}
|
|
plugin.apply(ctx, { minLevel: 'note', blocking: true, ...config })
|
|
for (const f of listeners.get('agent/created') ?? []) f({ agent })
|
|
const preStep = async (step) => {
|
|
let out
|
|
for (const f of listeners.get('agent/pre-step') ?? []) out = await f({ agent, messages: [], step, turn: 1 }, async () => ({ kind: 'enter', messages: [] }))
|
|
return out
|
|
}
|
|
const stopTurn = async () => { for (const f of listeners.get('agent/turn-stopping') ?? []) await f({ agent, turn: 1 }) }
|
|
const add = (role, content) => events.push({ msg: { role, content } })
|
|
/** Call the settings route like the web client does. */
|
|
const http = async (method, body, headers = {}, sid = sessionId) => {
|
|
const handler = routes.get('/omp-advisor/settings')
|
|
const req = Readable.from(body === undefined ? [] : [JSON.stringify(body)])
|
|
req.method = method
|
|
req.headers = headers
|
|
req.url = sid === null ? '/omp-advisor/settings' : `/omp-advisor/settings?session=${encodeURIComponent(sid)}`
|
|
let status = 200; let out = ''
|
|
const res = { statusCode: 200, setHeader() {}, end: (s) => { status = res.statusCode; out = s ?? '' } }
|
|
await handler(req, res)
|
|
return { status, json: out ? JSON.parse(out) : null }
|
|
}
|
|
return { preStep, stopTurn, add, steered, prompts, http }
|
|
}
|
|
|
|
test('parseVerdict', () => {
|
|
assert.deepEqual(plugin.parseVerdict('x {"verdict":"Concern","message":"m"} y'), { verdict: 'concern', message: 'm' })
|
|
assert.equal(plugin.parseVerdict('nope'), null)
|
|
assert.equal(plugin.parseVerdict('{"verdict":"maybe"}'), null)
|
|
})
|
|
|
|
test('a concern is injected into the next step as a system reminder', async () => {
|
|
const h = harness(['{"verdict":"concern","message":"You deleted tests/ without asking."}'])
|
|
h.add('user', [{ type: 'text', text: 'clean up the repo' }])
|
|
h.add('assistant', [{ type: 'tool-call', name: 'bash', arguments: { command: 'rm -rf tests/' } }])
|
|
h.add('tool', [{ type: 'text', text: '' }])
|
|
const d = await h.preStep(2)
|
|
assert.equal(d.kind, 'enter')
|
|
const text = d.messages.at(-1).content[0].text
|
|
assert.match(text, /Advisor concern\] You deleted tests\/ without asking/)
|
|
assert.equal(h.prompts[0].model, 'main-model')
|
|
assert.match(h.prompts[0].messages[0].content[0].text, /rm -rf tests/)
|
|
})
|
|
|
|
test('ok verdicts inject nothing; first step is never reviewed', async () => {
|
|
const h = harness(['{"verdict":"ok","message":""}'])
|
|
h.add('user', [{ type: 'text', text: 'hi' }])
|
|
assert.equal((await h.preStep(1)).messages.length, 0)
|
|
assert.equal(h.prompts.length, 0)
|
|
assert.equal((await h.preStep(2)).messages.length, 0)
|
|
})
|
|
|
|
test('blocker at turn end steers the agent', async () => {
|
|
const h = harness(['{"verdict":"blocker","message":"Do not push: tests are failing."}'], { blocking: false })
|
|
h.add('user', [{ type: 'text', text: 'ship it' }])
|
|
h.add('assistant', [{ type: 'tool-call', name: 'forge_push', arguments: {} }])
|
|
await h.preStep(2) // review starts in background
|
|
await new Promise((r) => setTimeout(r, 50))
|
|
await h.stopTurn()
|
|
assert.equal(h.steered.length, 1)
|
|
assert.match(h.steered[0].content[0].text, /ADVISOR BLOCKER.*Do not push/s)
|
|
})
|
|
|
|
test('minLevel filters notes and duplicates are suppressed', async () => {
|
|
const h = harness(['{"verdict":"note","message":"tiny"}', '{"verdict":"concern","message":"same"}', '{"verdict":"concern","message":"same"}'], { minLevel: 'concern' })
|
|
h.add('user', [{ type: 'text', text: 'x' }])
|
|
assert.equal((await h.preStep(2)).messages.length, 0) // note < minLevel: dropped
|
|
assert.equal((await h.preStep(3)).messages.length, 1) // concern (blocking review) delivered
|
|
assert.equal((await h.preStep(4)).messages.length, 0) // identical concern suppressed
|
|
})
|
|
|
|
test('settings route: per-session GET/POST, rejection, missing session, persistence', async () => {
|
|
const h = harness([], {}, { on: false })
|
|
assert.equal((await h.http('GET', undefined, { 'x-reject': '1' })).status, 401)
|
|
assert.equal((await h.http('GET', undefined, {}, null)).status, 400) // no ?session=
|
|
assert.equal((await h.http('GET', undefined, {}, '../etc')).status, 400) // invalid id
|
|
const g = await h.http('GET')
|
|
assert.equal(g.status, 200)
|
|
assert.equal(g.json.enabled, false) // off by default
|
|
assert.equal(g.json.sessionId, 'session-a')
|
|
const p = await h.http('POST', { enabled: true, provider: 'omni', model: 'cheap-model', reasoningEffort: 'low' })
|
|
assert.equal(p.json.model, 'cheap-model')
|
|
assert.equal(p.json.reasoningEffort, 'low')
|
|
const stored = JSON.parse(fs.readFileSync(SETTINGS, 'utf8'))
|
|
assert.deepEqual(Object.keys(stored), ['session-a'])
|
|
assert.equal(stored['session-a'].model, 'cheap-model')
|
|
assert.equal(typeof stored['session-a'].at, 'number')
|
|
assert.equal((await h.http('PUT', {})).status, 405)
|
|
})
|
|
|
|
test('default is off: no review for a session that never turned the advisor on', async () => {
|
|
const h = harness(['{"verdict":"concern","message":"should never be asked"}'], {}, { on: false })
|
|
h.add('user', [{ type: 'text', text: 'task' }])
|
|
assert.equal((await h.preStep(2)).messages.length, 0)
|
|
assert.equal(h.prompts.length, 0)
|
|
})
|
|
|
|
test('the old global omp-advisor.json is ignored (existing sessions stay off)', async () => {
|
|
fs.writeFileSync(path.join(PROFILE, 'omp-advisor.json'), JSON.stringify({ enabled: true, model: 'x' }))
|
|
const h = harness(['{"verdict":"ok","message":""}'], {}, { on: false })
|
|
h.add('user', [{ type: 'text', text: 'task' }])
|
|
await h.preStep(2)
|
|
assert.equal(h.prompts.length, 0)
|
|
assert.equal((await h.http('GET')).json.enabled, false)
|
|
fs.rmSync(path.join(PROFILE, 'omp-advisor.json'), { force: true })
|
|
})
|
|
|
|
test('sessions are independent: turning one on does not affect another', async () => {
|
|
const h = harness(['{"verdict":"ok","message":""}', '{"verdict":"ok","message":""}'], {}, { on: false })
|
|
h.add('user', [{ type: 'text', text: 'task' }])
|
|
await h.http('POST', { enabled: true, model: 'other-model', provider: 'omni' }, {}, 'session-b')
|
|
await h.preStep(2) // this agent is session-a
|
|
assert.equal(h.prompts.length, 0)
|
|
assert.equal((await h.http('GET', undefined, {}, 'session-b')).json.model, 'other-model')
|
|
assert.equal((await h.http('GET')).json.enabled, false)
|
|
await h.http('POST', { enabled: true })
|
|
await h.preStep(3)
|
|
assert.equal(h.prompts.length, 1)
|
|
assert.equal(h.prompts[0].model, 'main-model') // session-a uses the agent model, not session-b's
|
|
})
|
|
|
|
test('UI choice applies on the next review without restart; off disables reviews', async () => {
|
|
fs.rmSync(SETTINGS, { force: true })
|
|
const h = harness(['{"verdict":"ok","message":""}', '{"verdict":"ok","message":""}'])
|
|
h.add('user', [{ type: 'text', text: 'task' }])
|
|
await h.http('POST', { enabled: true, provider: 'omni', model: 'cheap-model', reasoningEffort: 'low' })
|
|
await h.preStep(2)
|
|
assert.equal(h.prompts[0].model, 'cheap-model')
|
|
assert.equal(h.prompts[0].reasoningEffort, 'low')
|
|
await h.http('POST', { provider: '', model: '', reasoningEffort: null }) // same as agent
|
|
await h.preStep(3)
|
|
assert.equal(h.prompts[1].model, 'main-model')
|
|
assert.equal(h.prompts[1].reasoningEffort, undefined)
|
|
await h.http('POST', { enabled: false })
|
|
await h.preStep(4)
|
|
assert.equal(h.prompts.length, 2) // no review while off
|
|
})
|