212 lines
10 KiB
JavaScript
212 lines
10 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { makeHarness, registerBuiltins } from './harness.mjs'
|
|
import * as plugin from '../index.js'
|
|
|
|
async function setup(config = {}, model) {
|
|
const h = await makeHarness()
|
|
registerBuiltins(h)
|
|
plugin.apply(h.ctx, config)
|
|
const agent = h.newAgent(model)
|
|
return { h, agent }
|
|
}
|
|
|
|
const tagOf = (text, file) => new RegExp(`\\[${file.replace('.', '\\.')}#([0-9A-F]{4})\\]`).exec(text)?.[1]
|
|
|
|
test('read is tagged; hashline edit applies, returns fresh tag + preview + diff', async () => {
|
|
const { h, agent } = await setup()
|
|
h.write('app.py', 'def f():\n return 1\n\nprint(f())\n')
|
|
const r = await h.call(agent, 'read', { file_path: 'app.py' })
|
|
const tag = tagOf(r.text, 'app.py')
|
|
assert.ok(tag, r.text)
|
|
assert.match(r.text, /^2: return 1$/m)
|
|
const e = await h.call(agent, 'edit', { input: `[app.py#${tag}]\nPUT 2.=2:\n+ return 2` })
|
|
assert.equal(h.read('app.py'), 'def f():\n return 2\n\nprint(f())\n')
|
|
const newTag = tagOf(e.text, 'app.py')
|
|
assert.ok(newTag && newTag !== tag, e.text)
|
|
assert.match(e.text, /2: return 2/)
|
|
assert.ok(e.meta.diffs.length >= 1)
|
|
h.cleanup()
|
|
})
|
|
|
|
test('edit without read is refused (DSH observation policy)', async () => {
|
|
const { h, agent } = await setup()
|
|
h.write('x.txt', 'a\n')
|
|
await assert.rejects(h.call(agent, 'edit', { input: '[x.txt#0000]\nPUT 1.=1:\n+b' }), /never issued|read/)
|
|
h.cleanup()
|
|
})
|
|
|
|
test('stale tag after external change is recovered when anchors survive', async () => {
|
|
const { h, agent } = await setup()
|
|
h.write('s.txt', 'alpha\nbeta\ngamma\n')
|
|
const tag = tagOf((await h.call(agent, 'read', { file_path: 's.txt' })).text, 's.txt')
|
|
h.write('s.txt', 'HEADER\nalpha\nbeta\ngamma\n') // external edit
|
|
const e = await h.call(agent, 'edit', { input: `[s.txt#${tag}]\nPUT 3.=3:\n+GAMMA` })
|
|
assert.equal(h.read('s.txt'), 'HEADER\nalpha\nbeta\nGAMMA\n')
|
|
assert.match(e.text, /recovered stale tag/)
|
|
h.cleanup()
|
|
})
|
|
|
|
test('stale tag with changed anchor is rejected, nothing written', async () => {
|
|
const { h, agent } = await setup()
|
|
h.write('s.txt', 'alpha\nbeta\ngamma\n')
|
|
const tag = tagOf((await h.call(agent, 'read', { file_path: 's.txt' })).text, 's.txt')
|
|
h.write('s.txt', 'alpha\nBETA\ngamma\n')
|
|
await assert.rejects(h.call(agent, 'edit', { input: `[s.txt#${tag}]\nPUT 2.=2:\n+x` }), /anchored lines moved or changed/)
|
|
assert.equal(h.read('s.txt'), 'alpha\nBETA\ngamma\n')
|
|
h.cleanup()
|
|
})
|
|
|
|
test('multi-file move with named register, then chained edit using returned tag', async () => {
|
|
const { h, agent } = await setup()
|
|
h.write('a.js', 'function helper() {\n return 1\n}\n\nhelper()\n')
|
|
h.write('b.js', '// lib\n')
|
|
const ta = tagOf((await h.call(agent, 'read', { file_path: 'a.js' })).text, 'a.js')
|
|
const tb = tagOf((await h.call(agent, 'read', { file_path: 'b.js' })).text, 'b.js')
|
|
const e = await h.call(agent, 'edit', { input: `[a.js#${ta}]\nCUT 1* @fn\nCUT 4.=4\n[b.js#${tb}]\nPUT >$ @fn` })
|
|
assert.equal(h.read('a.js'), 'helper()\n')
|
|
assert.equal(h.read('b.js'), '// lib\nfunction helper() {\n return 1\n}\n')
|
|
const tb2 = tagOf(e.text, 'b.js')
|
|
await h.call(agent, 'edit', { input: `[b.js#${tb2}]\nPUT 3.=3:\n+ return 2` })
|
|
assert.match(h.read('b.js'), /return 2/)
|
|
h.cleanup()
|
|
})
|
|
|
|
test('grep output is tagged and counts as an observation', async () => {
|
|
const { h, agent } = await setup()
|
|
h.write('g.txt', 'one\nneedle here\nthree\n')
|
|
const g = await h.call(agent, 'grep', { pattern: 'needle' })
|
|
const tag = tagOf(g.text, 'g.txt')
|
|
assert.ok(tag, g.text)
|
|
assert.match(g.text, /^2:needle here$/m)
|
|
await h.call(agent, 'edit', { input: `[g.txt#${tag}]\nPUT 2.=2:\n+found` })
|
|
assert.equal(h.read('g.txt'), 'one\nfound\nthree\n')
|
|
h.cleanup()
|
|
})
|
|
|
|
test('REM and MV', async () => {
|
|
const { h, agent } = await setup()
|
|
h.write('old.txt', 'x\n')
|
|
h.write('gone.txt', 'y\n')
|
|
const t1 = tagOf((await h.call(agent, 'read', { file_path: 'old.txt' })).text, 'old.txt')
|
|
const t2 = tagOf((await h.call(agent, 'read', { file_path: 'gone.txt' })).text, 'gone.txt')
|
|
await h.call(agent, 'edit', { input: `[old.txt#${t1}]\nPUT 1.=1:\n+z\nMV new.txt\n[gone.txt#${t2}]\nREM` })
|
|
assert.equal(h.read('new.txt'), 'z\n')
|
|
assert.throws(() => h.read('old.txt'))
|
|
assert.throws(() => h.read('gone.txt'))
|
|
h.cleanup()
|
|
})
|
|
|
|
test('CRLF files keep CRLF', async () => {
|
|
const { h, agent } = await setup()
|
|
h.write('w.txt', 'a\r\nb\r\n')
|
|
const tag = tagOf((await h.call(agent, 'read', { file_path: 'w.txt' })).text, 'w.txt')
|
|
await h.call(agent, 'edit', { input: `[w.txt#${tag}]\nPUT 2.=2:\n+c` })
|
|
assert.equal(h.read('w.txt'), 'a\r\nc\r\n')
|
|
h.cleanup()
|
|
})
|
|
|
|
test('DeepSeek model falls back to replace mode; strict keeps hashline', async () => {
|
|
const { h, agent } = await setup({}, 'deepseek-v4')
|
|
const t = h.ctx.tools.get('edit', agent)
|
|
assert.ok(t.parameters.properties.old_string, 'replace schema')
|
|
h.write('r.txt', 'hello world\n')
|
|
await h.call(agent, 'read', { file_path: 'r.txt' })
|
|
await h.call(agent, 'edit', { file_path: 'r.txt', edits: [{ old_string: 'hello', new_string: 'goodbye' }, { old_string: 'world', new_string: 'moon' }] })
|
|
assert.equal(h.read('r.txt'), 'goodbye moon\n')
|
|
h.cleanup()
|
|
const s = await setup({ strict: true }, 'deepseek-v4')
|
|
assert.ok(s.h.ctx.tools.get('edit', s.agent).parameters.properties.input)
|
|
s.h.cleanup()
|
|
})
|
|
|
|
test('replace mode refuses when file changed since read', async () => {
|
|
const { h, agent } = await setup({ mode: 'replace' })
|
|
h.write('c.txt', 'v1\n')
|
|
await h.call(agent, 'read', { file_path: 'c.txt' })
|
|
h.write('c.txt', 'v2\n')
|
|
await assert.rejects(h.call(agent, 'edit', { file_path: 'c.txt', old_string: 'v2', new_string: 'v3' }), /changed since it was read/)
|
|
h.cleanup()
|
|
})
|
|
|
|
test('sloppy and apply_patch modes', async () => {
|
|
const s = await setup({ mode: 'sloppy' })
|
|
s.h.write('u.ts', 'const a = 1;\nrun(a);\n')
|
|
await s.h.call(s.agent, 'read', { file_path: 'u.ts' })
|
|
await s.h.call(s.agent, 'edit', { input: '*** Edit File: u.ts\n*** Find\nrun(a);\n*** Insert Before\nlog(a);' })
|
|
assert.equal(s.h.read('u.ts'), 'const a = 1;\nlog(a);\nrun(a);\n')
|
|
s.h.cleanup()
|
|
const p = await setup({ mode: 'apply_patch' })
|
|
p.h.write('m.py', 'def f():\n return 1\n')
|
|
await p.h.call(p.agent, 'read', { file_path: 'm.py' })
|
|
await p.h.call(p.agent, 'edit', { input: '*** Begin Patch\n*** Update File: m.py\n def f():\n- return 1\n+ return 2\n*** Add File: n.py\n+x = 1\n*** End Patch' })
|
|
assert.equal(p.h.read('m.py'), 'def f():\n return 2\n')
|
|
assert.equal(p.h.read('n.py'), 'x = 1\n')
|
|
p.h.cleanup()
|
|
})
|
|
|
|
test('model switch re-registers the edit tool in the agent scope', async () => {
|
|
const { h, agent } = await setup()
|
|
assert.ok(h.ctx.tools.get('edit', agent).parameters.properties.input)
|
|
await h.ctx.waterfall('agent/request', {}, async () => ({ provider: 'omni', model: 'deepseek-chat' }))
|
|
assert.ok(h.ctx.tools.get('edit', agent).parameters.properties.old_string)
|
|
h.cleanup()
|
|
})
|
|
|
|
test('guard denies shell edits/reads/searches and non-hashline write tools via tools/pre-execute', async () => {
|
|
const { h } = await setup()
|
|
const gate = async (name, args) => {
|
|
const cbs = [...(h.listeners.get('tools/pre-execute') ?? [])]
|
|
const exec = { name, arguments: args, agent: { session: { header: { cwd: h.dir } } }, signal: new AbortController().signal }
|
|
const next = () => (cbs.shift() ?? (async () => ({ kind: 'allow' })))(exec, next)
|
|
return next()
|
|
}
|
|
assert.equal((await gate('bash', { command: 'sed -i s/a/b/ f.txt' })).kind, 'deny')
|
|
assert.equal((await gate('bash', { command: 'grep -rn foo .' })).kind, 'allow')
|
|
assert.equal((await gate('bash', { command: 'cat README.md' })).kind, 'allow')
|
|
assert.equal((await gate('bash', { command: 'sed -n 1,5p README.md' })).kind, 'allow')
|
|
assert.equal((await gate('bash', { command: 'cat a > b' })).kind, 'deny')
|
|
h.write('exists.txt', 'old\n')
|
|
const d = await gate('write', { file_path: 'exists.txt', content: 'y' })
|
|
assert.equal(d.kind, 'deny')
|
|
assert.match(d.reason, /only create new files/)
|
|
assert.equal((await gate('write', { file_path: 'brand-new.txt', content: 'y' })).kind, 'allow')
|
|
assert.equal((await gate('str_replace_editor', { command: 'create', path: 'z' })).kind, 'deny')
|
|
// A stat failure must not fail open.
|
|
const realStat = h.ctx.fs.stat
|
|
h.ctx.fs.stat = async () => { throw Object.assign(new Error('permission denied'), { code: 'EACCES' }) }
|
|
const e = await gate('write', { file_path: 'locked.txt', content: 'y' })
|
|
h.ctx.fs.stat = realStat
|
|
assert.equal(e.kind, 'deny')
|
|
assert.match(e.reason, /could not check/)
|
|
assert.equal((await gate('bash', { command: 'npm test 2>&1 | tail -5' })).kind, 'allow')
|
|
assert.equal((await gate('edit', { input: '[a#0000]\nPUT 1.=1:\n+x' })).kind, 'allow')
|
|
h.cleanup()
|
|
})
|
|
|
|
test('grep pages at pageLines with an offset cursor', async () => {
|
|
const { h, agent } = await setup({ pageLines: 3 })
|
|
h.write('many.txt', Array.from({ length: 8 }, (_, i) => `hit ${i}`).join('\n') + '\n')
|
|
const p1 = await h.call(agent, 'grep', { pattern: 'hit' })
|
|
assert.match(p1.text, /8 matches in 1 file — showing 1-3/)
|
|
assert.match(p1.text, /call grep again with offset=3/)
|
|
const p2 = await h.call(agent, 'grep', { pattern: 'hit', offset: 6 })
|
|
assert.match(p2.text, /showing 7-8/)
|
|
assert.match(p2.text, /^8:hit 7$/m)
|
|
assert.doesNotMatch(p2.text, /offset=/)
|
|
h.cleanup()
|
|
})
|
|
|
|
test('edit result diffs carry file line numbers and rows for the gutter', async () => {
|
|
const { h, agent } = await setup()
|
|
h.write('long.txt', Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join('\n') + '\n')
|
|
const tag = /#([0-9A-F]{4})\]/.exec((await h.call(agent, 'read', { file_path: 'long.txt' })).text)[1]
|
|
const e = await h.call(agent, 'edit', { input: `[long.txt#${tag}]\nPUT 20.=20:\n+LINE TWENTY\n+extra` })
|
|
const [d] = e.meta.diffs
|
|
assert.equal(d.oldStart, 17)
|
|
assert.equal(d.newStart, 17)
|
|
assert.deepEqual(d.rows.slice(2, 6), [['=', 'line 19'], ['-', 'line 20'], ['+', 'LINE TWENTY'], ['+', 'extra']])
|
|
assert.equal(typeof d.oldText, 'string') // stock DiffHunk fields stay intact
|
|
h.cleanup()
|
|
})
|