Initial commit: omp-edit DSH plugin (oh-my-pi port) with docs and tests
This commit is contained in:
commit
71953ffb8f
19 files changed
+2412
No files matched your search
@@ -0,0 +1,144 @@
|
||||
// node --test test/
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { parseHashline, applySection } from '../lib/hashline.js'
|
||||
import { resolveBlock } from '../lib/block.js'
|
||||
import { SnapshotStore, rebaseOps, lineMap } from '../lib/store.js'
|
||||
import { applyReplace } from '../lib/replace.js'
|
||||
import { parseSloppy, applySloppy } from '../lib/sloppy.js'
|
||||
import { parseApplyPatch, applyUpdate } from '../lib/apply_patch.js'
|
||||
import { fileTag, splitLines, joinLines } from '../lib/text.js'
|
||||
|
||||
const run = (path, text, patch, registers = new Map()) => {
|
||||
const { sections, warnings } = parseHashline(patch)
|
||||
const { lines, eol } = splitLines(text)
|
||||
const r = applySection(sections[0], { path, lines, registers, anon: { value: null }, warnings })
|
||||
return { text: joinLines(r.lines, eol), r, warnings }
|
||||
}
|
||||
|
||||
const py = '@cache\ndef greet(name):\n print("Hello, " + name)\n\ngreet("world")\n'
|
||||
|
||||
test('PUT range replaces one line', () => {
|
||||
const out = run('greet.py', py, `[greet.py#${fileTag(py)}]\nPUT 5.=5:\n+greet("team")`)
|
||||
assert.equal(out.text, '@cache\ndef greet(name):\n print("Hello, " + name)\n\ngreet("team")\n')
|
||||
})
|
||||
|
||||
test('PUT N* replaces a decorated python block', () => {
|
||||
const out = run('greet.py', py, `[greet.py#ABCD]\nPUT 1*:\n+@cache\n+def greet(name):\n+ print(f"Hi, {name}")`)
|
||||
assert.equal(out.text, '@cache\ndef greet(name):\n print(f"Hi, {name}")\n\ngreet("world")\n')
|
||||
})
|
||||
|
||||
test('gap inserts and tail append', () => {
|
||||
const t = 'a\nb\nc\n'
|
||||
assert.equal(run('x.txt', t, '[x.txt#0000]\nPUT <1:\n+head').text, 'head\na\nb\nc\n')
|
||||
assert.equal(run('x.txt', t, '[x.txt#0000]\nPUT >2:\n+mid').text, 'a\nb\nmid\nc\n')
|
||||
assert.equal(run('x.txt', t, '[x.txt#0000]\nPUT >$:\n+tail').text, 'a\nb\nc\ntail\n')
|
||||
})
|
||||
|
||||
test('multiple hunks use original numbering', () => {
|
||||
const t = '1\n2\n3\n4\n5\n'
|
||||
const out = run('n.txt', t, '[n.txt#0000]\nPUT 1.=1:\n+one\n+uno\nCUT 3.=3\nPUT 5.=5:\n+five')
|
||||
assert.equal(out.text, 'one\nuno\n2\n4\nfive\n')
|
||||
})
|
||||
|
||||
test('overlap is rejected', () => {
|
||||
assert.throws(() => run('n.txt', '1\n2\n3\n', '[n.txt#0000]\nPUT 1.=2:\n+x\nPUT 2.=3:\n+y'), /overlap/)
|
||||
})
|
||||
|
||||
test('CUT + paste via anon register in one section', () => {
|
||||
const t = 'a\nb\nc\nd\n'
|
||||
const out = run('m.txt', t, '[m.txt#0000]\nCUT 1.=2\nPUT >4')
|
||||
assert.equal(out.text, 'c\nd\na\nb\n')
|
||||
})
|
||||
|
||||
test('JS block resolution and >N* insert', () => {
|
||||
const js = 'function a() {\n if (x) {\n y()\n } else {\n z()\n }\n}\nconst b = 1\n'
|
||||
assert.deepEqual(resolveBlock('a.js', splitLines(js).lines, 1), { start: 1, end: 7 })
|
||||
assert.deepEqual(resolveBlock('a.js', splitLines(js).lines, 2), { start: 2, end: 6 })
|
||||
const out = run('a.js', js, '[a.js#0000]\nPUT >1*:\n+\n+function c() {}')
|
||||
assert.equal(out.text, 'function a() {\n if (x) {\n y()\n } else {\n z()\n }\n}\n\nfunction c() {}\nconst b = 1\n')
|
||||
})
|
||||
|
||||
test('markdown heading block', () => {
|
||||
const md = '# A\ntext\n## A1\nmore\n# B\nb\n'
|
||||
assert.deepEqual(resolveBlock('r.md', splitLines(md).lines, 1), { start: 1, end: 4 })
|
||||
assert.deepEqual(resolveBlock('r.md', splitLines(md).lines, 3), { start: 3, end: 4 })
|
||||
})
|
||||
|
||||
test('block on single line is rejected', () => {
|
||||
assert.throws(() => resolveBlock('a.js', ['const x = 1', 'y'], 1), /does not open a multi-line block/)
|
||||
})
|
||||
|
||||
test('body rows must be +', () => {
|
||||
assert.throws(() => parseHashline('[a#0000]\nPUT 1.=1:\n-old\n+new'), /must start with '\+'/)
|
||||
})
|
||||
|
||||
test('loose range spelling is accepted with a warning', () => {
|
||||
const out = run('n.txt', '1\n2\n3\n', '[n.txt#0000]\nPUT 2-3:\n+x')
|
||||
assert.equal(out.text, '1\nx\n')
|
||||
assert.match(out.warnings.join(), /canonical range/)
|
||||
})
|
||||
|
||||
test('stale tag recovery remaps lines shifted by an external insert', () => {
|
||||
const v1 = 'alpha\nbeta\ngamma\ndelta\n'
|
||||
const v2 = 'NEW\nalpha\nbeta\ngamma\ndelta\n'
|
||||
const { sections } = parseHashline('[f#0000]\nPUT 3.=3:\n+GAMMA')
|
||||
const rb = rebaseOps(v1, v2, sections[0].ops)
|
||||
assert.ok(rb)
|
||||
assert.equal(rb.ops[0].start, 4)
|
||||
})
|
||||
|
||||
test('stale tag recovery refuses when the anchored line changed', () => {
|
||||
const v1 = 'alpha\nbeta\ngamma\ndelta\n'
|
||||
const v2 = 'alpha\nbeta\nGAMMA!\ndelta\n'
|
||||
const { sections } = parseHashline('[f#0000]\nPUT 3.=3:\n+x')
|
||||
assert.equal(rebaseOps(v1, v2, sections[0].ops), null)
|
||||
})
|
||||
|
||||
test('lineMap via Myers', () => {
|
||||
const m = lineMap(['a', 'b', 'c', 'd'], ['a', 'x', 'c', 'd', 'e'])
|
||||
assert.equal(m.get(1), 1); assert.equal(m.get(2), undefined); assert.equal(m.get(3), 3); assert.equal(m.get(4), 4)
|
||||
})
|
||||
|
||||
test('snapshot store records and finds tags', () => {
|
||||
const s = new SnapshotStore()
|
||||
const tag = s.record('k', 'hello\n')
|
||||
assert.match(tag, /^[0-9A-F]{4}$/)
|
||||
assert.equal(s.byTag('k', tag).text, 'hello\n')
|
||||
})
|
||||
|
||||
test('replace: exact, ambiguous, fuzzy indentation', () => {
|
||||
assert.equal(applyReplace('a = 1\nb = 2\n', 'b = 2', 'b = 3').text, 'a = 1\nb = 3\n')
|
||||
assert.throws(() => applyReplace('x\nx\n', 'x', 'y'), /matched 2 times/)
|
||||
assert.equal(applyReplace('x\nx\n', 'x', 'y', { all: true }).text, 'y\ny\n')
|
||||
const src = 'def f():\n if a:\n return 1\n'
|
||||
const r = applyReplace(src, 'if a:\n return 1', 'if a:\n return 2')
|
||||
assert.equal(r.text, 'def f():\n if a:\n return 2\n')
|
||||
assert.equal(r.kind, 'trimmed')
|
||||
const q = applyReplace('say("hi")\n', 'say(\u201chi\u201d)', 'say("yo")')
|
||||
assert.equal(q.text, 'say("yo")\n')
|
||||
})
|
||||
|
||||
test('replace: not-found gives closest hint', () => {
|
||||
assert.throws(() => applyReplace('function alpha(beta) {}\n', 'function alpha(betaa) {}', 'x', { fuzzy: false }), /not found/)
|
||||
})
|
||||
|
||||
test('sloppy: replace, insert before/after, captures', () => {
|
||||
const t = 'const helper = () => 1;\nfunction run(target) {\n run(target);\n}\n'
|
||||
const [f] = parseSloppy('*** Edit File: a.js\n*** Find\nconst helper = () => 1;\n*** Replace\nconst helper = () => 2;\n*** Find\n run(target);\n*** Insert Before\n log(target);')
|
||||
assert.equal(applySloppy(t, f), 'const helper = () => 2;\nfunction run(target) {\n log(target);\n run(target);\n}\n')
|
||||
const [g] = parseSloppy('*** Edit File: a.js\n*** Find\nfunction run(…) {\n*** Replace\nfunction run(…, opts) {')
|
||||
assert.equal(applySloppy(t, g), 'const helper = () => 1;\nfunction run(target, opts) {\n run(target);\n}\n')
|
||||
})
|
||||
|
||||
test('apply_patch: update with context + add + move', () => {
|
||||
const ops = parseApplyPatch('*** Begin Patch\n*** Update File: a.py\n@@ def f\n def f():\n- return 1\n+ return 2\n*** Add File: b.py\n+x = 1\n*** End Patch')
|
||||
assert.equal(ops.length, 2)
|
||||
const { lines } = applyUpdate(['import os', 'def f():', ' return 1', ''], ops[0])
|
||||
assert.deepEqual(lines, ['import os', 'def f():', ' return 2', ''])
|
||||
assert.deepEqual(ops[1].lines, ['x = 1'])
|
||||
})
|
||||
|
||||
test('header without tag gives actionable error', () => {
|
||||
assert.throws(() => parseHashline('[a.js]\nPUT 1.=1:\n+x'), /missing the 4-hex snapshot tag/)
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
// Minimal DSH-like context for integration tests: real @deepseek-ai/dsh-fs-local backend,
|
||||
// the fs-observation-policy rules, a scoped tool registry, and fake agents.
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
const DSH = '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai'
|
||||
|
||||
export async function makeHarness() {
|
||||
const { default: LocalFileSystem } = await import(`${DSH}/dsh-fs-local/lib/index.js`)
|
||||
const { FsError } = await import(`${DSH}/dsh-fs/lib/index.js`)
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-edit-'))
|
||||
const listeners = new Map()
|
||||
const on = (name, fn, opts) => {
|
||||
const list = listeners.get(name) ?? []
|
||||
if (opts?.prepend) list.unshift(fn); else list.push(fn)
|
||||
listeners.set(name, list)
|
||||
return () => listeners.set(name, (listeners.get(name) ?? []).filter((f) => f !== fn))
|
||||
}
|
||||
const fsCtx = { config: { cwd: dir }, effect() {}, on() {}, get() {}, provide() {}, emit() {} }
|
||||
// Construct without a Cordis context: borrow the prototype and the instance fields.
|
||||
const fsImpl = Object.create(LocalFileSystem.prototype)
|
||||
Object.assign(fsImpl, { config: { cwd: dir, diffBasisMaxBytes: 10 * 1024 * 1024 }, internals: {}, locks: new Map() })
|
||||
void fsCtx
|
||||
|
||||
// observation policy (copy of DSH's rules)
|
||||
const observed = new WeakMap()
|
||||
const own = (actor) => actor?.agent?.session
|
||||
on('fs/write-intent', async (target, actor) => {
|
||||
const p = own(actor) && observed.get(own(actor))?.get(target.targetKey)
|
||||
return p?.kind === 'present' ? { kind: 'replaceIfVersion', version: p.version } : { kind: 'createIfAbsent' }
|
||||
})
|
||||
on('fs/edit-intent', async (target, actor) => {
|
||||
const o = own(actor); const p = o && observed.get(o)?.get(target.targetKey)
|
||||
if (!o || p === undefined) throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
|
||||
if (p.kind === 'absent') throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
return { version: p.version }
|
||||
})
|
||||
on('fs/observed', (target, obs, actor) => {
|
||||
const o = own(actor); if (!o) return
|
||||
if (!observed.has(o)) observed.set(o, new Map())
|
||||
observed.get(o).set(target.targetKey, obs)
|
||||
})
|
||||
|
||||
const global = new Map()
|
||||
const scoped = new WeakMap()
|
||||
const makeTools = (scope) => ({
|
||||
register(def) {
|
||||
const m = scope ? (scoped.get(scope) ?? scoped.set(scope, new Map()).get(scope)) : global
|
||||
if (m.has(def.name)) throw new Error(`duplicate tool ${def.name}`)
|
||||
m.set(def.name, def)
|
||||
return () => m.delete(def.name)
|
||||
},
|
||||
get(name, s) { return (s && scoped.get(s)?.get(name)) ?? global.get(name) },
|
||||
})
|
||||
const agents = []
|
||||
const sp = { section: () => () => {}, getSectionOrder: () => 1300 }
|
||||
const ctx = {
|
||||
fs: fsImpl,
|
||||
tools: makeTools(undefined),
|
||||
agents: { list: () => agents },
|
||||
systemPrompt: sp,
|
||||
on,
|
||||
emit(name, ...args) { for (const f of listeners.get(name) ?? []) f(...args) },
|
||||
async waterfall(name, ...args) {
|
||||
const inner = args.pop()
|
||||
const cbs = [...(listeners.get(name) ?? [])]
|
||||
const next = () => (cbs.shift() ?? inner)(...args, next)
|
||||
return next()
|
||||
},
|
||||
effect(fn) { const d = fn(); return d },
|
||||
get() { return undefined },
|
||||
}
|
||||
function newAgent(model = 'anthropic/claude-sonnet') {
|
||||
const agent = { options: { model }, session: { header: { cwd: dir } } }
|
||||
agent.ctx = { tools: makeTools(agent), on, systemPrompt: sp }
|
||||
agents.push(agent)
|
||||
for (const f of listeners.get('agent/created') ?? []) f({ agent })
|
||||
return agent
|
||||
}
|
||||
const call = async (agent, name, args) => {
|
||||
const t = ctx.tools.get(name, agent)
|
||||
if (!t) throw new Error(`no tool ${name}`)
|
||||
const exec = { agent, name, arguments: args, signal: new AbortController().signal }
|
||||
const value = await t.execute(args, exec)
|
||||
const content = t.output.render(args, value)
|
||||
const meta = t.output.presentationMeta?.(args, value)
|
||||
return { value, text: content.map((c) => c.text).join('\n'), meta }
|
||||
}
|
||||
const write = (rel, text) => fs.writeFileSync(path.join(dir, rel), text)
|
||||
const readF = (rel) => fs.readFileSync(path.join(dir, rel), 'utf8')
|
||||
return { ctx, dir, newAgent, call, write, read: readF, listeners, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
/** Fake built-in read/grep with DSH's output shapes. */
|
||||
export function registerBuiltins(h) {
|
||||
h.ctx.tools.register({
|
||||
name: 'read',
|
||||
description: 'Read a UTF-8 text file and return line-numbered content.',
|
||||
parameters: { type: 'object', properties: { file_path: { type: 'string' } }, required: ['file_path'] },
|
||||
output: {
|
||||
schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string' }, offset: { type: 'integer' }, lines: { type: 'array' }, totalLines: { type: 'integer' } } },
|
||||
render: (_a, v) => [{ type: 'text', text: `<path>${v.path}</path>\n<type>file</type>\n<content>\n${v.lines.map((l) => `${l.number}: ${l.text}`).join('\n')}\n\n(End of file - total ${v.totalLines} lines)\n</content>` }],
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const t = await h.ctx.fs.resolve(args.file_path, { cwd: h.dir })
|
||||
const info = await h.ctx.fs.stat(t)
|
||||
const text = await h.ctx.fs.readText(t)
|
||||
const lines = text.replace(/\n$/, '').split('\n').map((x, i) => ({ number: i + 1, text: x }))
|
||||
h.ctx.emit('fs/observed', t, { kind: 'present', version: info.version }, exec)
|
||||
return { path: t.displayPath, offset: 1, lines, totalLines: lines.length }
|
||||
},
|
||||
})
|
||||
h.ctx.tools.register({
|
||||
name: 'grep',
|
||||
description: 'Search file contents.',
|
||||
parameters: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'] },
|
||||
output: {
|
||||
schema: { type: 'object', additionalProperties: false, properties: { matches: { type: 'array' } } },
|
||||
render: (_a, v) => [{ type: 'text', text: v.matches.map((m) => `${m.path}:${m.lineNumber}: ${m.line}`).join('\n') || 'No matches' }],
|
||||
},
|
||||
async execute(args) {
|
||||
const out = []
|
||||
for (const f of fs.readdirSync(h.dir)) {
|
||||
const p = path.join(h.dir, f)
|
||||
if (!fs.statSync(p).isFile()) continue
|
||||
fs.readFileSync(p, 'utf8').split('\n').forEach((l, i) => { if (new RegExp(args.pattern).test(l)) out.push({ path: f, lineNumber: i + 1, line: l }) })
|
||||
}
|
||||
return { matches: out }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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()
|
||||
})
|
||||
Reference in new issue
Block a user