1018 lines
29 KiB
JavaScript
1018 lines
29 KiB
JavaScript
/**
|
|
* Guest-safe Grok Build ports: glob, todos, plan mode, memory, hooks, edits.
|
|
*/
|
|
import test from 'brittle'
|
|
import { readFileSync } from 'node:fs'
|
|
import vm from 'node:vm'
|
|
|
|
const STATE_SRC = readFileSync(new URL('../lib/agent/agent-state.js', import.meta.url), 'utf8')
|
|
const HELPERS_SRC = readFileSync(
|
|
new URL('../lib/agent/agent-helpers.js', import.meta.url),
|
|
'utf8'
|
|
)
|
|
const PORT_SRC = readFileSync(
|
|
new URL('../lib/agent/agent-grok-port.js', import.meta.url),
|
|
'utf8'
|
|
)
|
|
const TOOLS_SRC =
|
|
readFileSync(new URL('../lib/agent/agent-tools.js', import.meta.url), 'utf8') +
|
|
'\n' +
|
|
readFileSync(
|
|
new URL('../lib/agent/agent-tool-definitions.js', import.meta.url),
|
|
'utf8'
|
|
) +
|
|
'\n' +
|
|
readFileSync(
|
|
new URL('../lib/agent/agent-tool-dispatch.js', import.meta.url),
|
|
'utf8'
|
|
)
|
|
|
|
function loadPort() {
|
|
const sandbox = { TextDecoder, TextEncoder, Uint8Array, console }
|
|
vm.createContext(sandbox)
|
|
vm.runInContext(PORT_SRC, sandbox, { filename: 'agent-grok-port.js' })
|
|
return sandbox
|
|
}
|
|
|
|
function loadDispatch() {
|
|
const sandbox = { TextDecoder, TextEncoder, Uint8Array, console }
|
|
vm.createContext(sandbox)
|
|
vm.runInContext(STATE_SRC, sandbox, { filename: 'agent-state.js' })
|
|
vm.runInContext(HELPERS_SRC, sandbox, { filename: 'agent-helpers.js' })
|
|
vm.runInContext(PORT_SRC, sandbox, { filename: 'agent-grok-port.js' })
|
|
vm.runInContext(TOOLS_SRC, sandbox, { filename: 'agent-tools.js' })
|
|
return sandbox
|
|
}
|
|
|
|
function makeVfs(initial) {
|
|
/** @type {Map<string, string>} */
|
|
const files = new Map(Object.entries(initial || {}))
|
|
/** @type {Set<string>} */
|
|
const dirs = new Set(['/'])
|
|
for (const p of files.keys()) {
|
|
let dir = String(p).replace(/\/[^/]+$/, '')
|
|
while (dir && dir !== '/') {
|
|
dirs.add(dir)
|
|
dir = dir.replace(/\/[^/]+$/, '')
|
|
}
|
|
}
|
|
const b4a = {
|
|
from(v) {
|
|
return new TextEncoder().encode(typeof v === 'string' ? v : String(v))
|
|
},
|
|
toString(v) {
|
|
return new TextDecoder().decode(v)
|
|
}
|
|
}
|
|
const vfs = {
|
|
async mkdir(p) {
|
|
dirs.add(String(p).replace(/\/+$/, '') || '/')
|
|
},
|
|
async readFile(p) {
|
|
if (!files.has(p)) throw new Error('enoent:' + p)
|
|
return b4a.from(files.get(p) || '')
|
|
},
|
|
async writeFile(p, buf) {
|
|
const txt = buf instanceof Uint8Array ? new TextDecoder().decode(buf) : String(buf)
|
|
files.set(p, txt)
|
|
},
|
|
async unlink(p) {
|
|
if (!files.has(p)) throw new Error('enoent:' + p)
|
|
files.delete(p)
|
|
},
|
|
async rm(p, opts) {
|
|
if (opts && opts.recursive) {
|
|
for (const key of [...files.keys()]) {
|
|
if (key === p || String(key).startsWith(String(p) + '/')) files.delete(key)
|
|
}
|
|
return
|
|
}
|
|
if (!files.has(p)) throw new Error('enoent:' + p)
|
|
files.delete(p)
|
|
},
|
|
async readdir(p) {
|
|
const root = String(p).replace(/\/+$/, '') || '/'
|
|
if (!dirs.has(root) && ![...files.keys()].some((k) => k.startsWith(root + '/'))) {
|
|
throw new Error('enoent:' + p)
|
|
}
|
|
const prefix = root === '/' ? '/' : root + '/'
|
|
const names = new Set()
|
|
for (const key of [...files.keys(), ...dirs]) {
|
|
if (key === root) continue
|
|
if (!String(key).startsWith(prefix)) continue
|
|
const rest = String(key).slice(prefix.length)
|
|
const name = rest.split('/')[0]
|
|
if (name) names.add(name)
|
|
}
|
|
return [...names]
|
|
},
|
|
async lstat(p) {
|
|
const n = String(p).replace(/\/+$/, '') || '/'
|
|
if (dirs.has(n)) return { isDirectory: () => true }
|
|
if (files.has(n)) return { isDirectory: () => false }
|
|
throw new Error('enoent:' + p)
|
|
}
|
|
}
|
|
return { vfs, files, dirs, b4a }
|
|
}
|
|
|
|
async function dispatch(s, o) {
|
|
const fn = /** @type {(arg: object) => Promise<string>} */ (s.bareAgentDispatchTool)
|
|
const raw = await fn({
|
|
ctx: o.ctx,
|
|
paths: o.paths,
|
|
toolName: o.toolName,
|
|
argsJson: JSON.stringify(o.args || {}),
|
|
configRef: o.configRef || { current: {} },
|
|
appendProgress: o.appendProgress || (() => {}),
|
|
home: o.home || '/home/guest',
|
|
signal: null,
|
|
onTaskComplete: o.onTaskComplete || (() => {})
|
|
})
|
|
return JSON.parse(raw)
|
|
}
|
|
|
|
test('glob ** and * match relative and basename paths', async (t) => {
|
|
const s = loadPort()
|
|
t.ok(s.bareAgentGlobMatch('src/foo.js', '**/*.js'))
|
|
t.ok(s.bareAgentGlobMatch('foo.js', '*.js'))
|
|
t.ok(s.bareAgentGlobMatch('src/foo.js', '*.js'))
|
|
t.absent(s.bareAgentGlobMatch('src/foo.md', '**/*.js'))
|
|
})
|
|
|
|
test('todo merge updates by id and summarize counts open items', async (t) => {
|
|
const s = loadPort()
|
|
const first = s.bareAgentTodoApply(
|
|
[
|
|
{ id: 'a', content: 'one', status: 'pending' },
|
|
{ id: 'b', content: 'two', status: 'in_progress' }
|
|
],
|
|
{ merge: false },
|
|
[]
|
|
)
|
|
const next = s.bareAgentTodoApply(
|
|
[{ id: 'a', status: 'completed' }],
|
|
{ merge: true },
|
|
first
|
|
)
|
|
t.is(next.length, 2)
|
|
t.is(next[0].status, 'completed')
|
|
t.is(next[0].content, 'one')
|
|
const sum = s.bareAgentTodoSummarize(next)
|
|
t.is(sum.open, 1)
|
|
t.is(sum.completed, 1)
|
|
})
|
|
|
|
test('todo_write rejects empty and duplicate ids', async (t) => {
|
|
const s = loadPort()
|
|
t.exception(() => s.bareAgentTodoApply([{ content: 'x' }], { merge: false }, []))
|
|
t.exception(() =>
|
|
s.bareAgentTodoApply(
|
|
[
|
|
{ id: 'a', content: 'one' },
|
|
{ id: 'a', content: 'two' }
|
|
],
|
|
{ merge: false },
|
|
[]
|
|
)
|
|
)
|
|
})
|
|
|
|
test('search_replace requires unique old_string unless replace_all', async (t) => {
|
|
const s = loadPort()
|
|
const text = 'aaa\nbbb\naaa\n'
|
|
const miss = s.bareAgentSearchReplaceApply(text, 'zzz', 'q', false)
|
|
t.absent(miss.ok)
|
|
t.is(miss.error, 'old_string not found')
|
|
const many = s.bareAgentSearchReplaceApply(text, 'aaa', 'ccc', false)
|
|
t.absent(many.ok)
|
|
t.is(many.error, 'old_string not unique')
|
|
t.is(many.count, 2)
|
|
const all = s.bareAgentSearchReplaceApply(text, 'aaa', 'ccc', true)
|
|
t.ok(all.ok)
|
|
t.is(all.replacements, 2)
|
|
t.ok(String(all.next).includes('ccc\nbbb\nccc'))
|
|
})
|
|
|
|
test('slice file lines is 1-based and numbered', async (t) => {
|
|
const s = loadPort()
|
|
const sliced = s.bareAgentSliceFileLines('a\nb\nc\nd\n', { offset: 2, limit: 2 })
|
|
t.is(sliced.start_line, 2)
|
|
t.is(sliced.end_line, 3)
|
|
t.is(sliced.total_lines, 4)
|
|
t.ok(sliced.truncated)
|
|
t.is(sliced.content, '2→b\n3→c')
|
|
})
|
|
|
|
test('plan mode allows reads and plan.md writes only', async (t) => {
|
|
const s = loadPort()
|
|
const plan = '/home/guest/.agent/plan.md'
|
|
t.ok(s.bareAgentPlanModeToolAllowed('read_file', { path: '/etc/hosts' }, { plan }))
|
|
t.ok(s.bareAgentPlanModeToolAllowed('write_file', { path: plan }, { plan }))
|
|
t.absent(
|
|
s.bareAgentPlanModeToolAllowed('write_file', { path: '/home/guest/x.js' }, { plan })
|
|
)
|
|
t.ok(s.bareAgentPlanModeToolAllowed('todo_write', {}, { plan }))
|
|
})
|
|
|
|
test('todo nudge appears after idle turns with open items', async (t) => {
|
|
const s = loadPort()
|
|
t.is(s.bareAgentTodoNudgeText({ open: 2, turnsSinceTodoWrite: 2 }), '')
|
|
t.ok(s.bareAgentTodoNudgeText({ open: 2, turnsSinceTodoWrite: 3 }).includes('Open todos'))
|
|
t.ok(
|
|
s.bareAgentTodoNudgeText({ open: 0, turnsSinceTodoWrite: 5 }).includes('todo_write')
|
|
)
|
|
t.is(s.bareAgentTodoNudgeText({ open: 2, turnsSinceTodoWrite: 9, nudgeEnabled: false }), '')
|
|
})
|
|
|
|
test('pre-tool hook denies matching tool + regex', async (t) => {
|
|
const s = loadPort()
|
|
const reason = s.bareAgentHookDenies(
|
|
{
|
|
event: 'PreToolUse',
|
|
tools: ['run_command'],
|
|
deny_regex: 'rm\\s+-rf',
|
|
reason: 'no recursive delete'
|
|
},
|
|
'run_command',
|
|
{ command: 'rm -rf /tmp/x' }
|
|
)
|
|
t.is(reason, 'no recursive delete')
|
|
t.is(
|
|
s.bareAgentHookDenies(
|
|
{ event: 'PreToolUse', tools: ['run_command'], deny_regex: 'rm\\s+-rf' },
|
|
'read_file',
|
|
{ path: '/tmp/x' }
|
|
),
|
|
''
|
|
)
|
|
})
|
|
|
|
test('memory score ranks files that contain more query tokens', async (t) => {
|
|
const s = loadPort()
|
|
const tokens = s.bareAgentMemoryTokens('holesail discord whitelist')
|
|
t.ok(tokens.includes('holesail'))
|
|
t.ok(
|
|
s.bareAgentMemoryScore('holesail discord whitelist notes', tokens) >
|
|
s.bareAgentMemoryScore('unrelated pear notes', tokens)
|
|
)
|
|
})
|
|
|
|
test('walk-up discovers AGENTS.md and .grok/rules', async (t) => {
|
|
const s = loadPort()
|
|
const { vfs, b4a } = makeVfs({
|
|
'/home/guest/proj/src/AGENTS.md': 'leaf',
|
|
'/home/guest/proj/AGENTS.md': 'mid',
|
|
'/home/guest/proj/.grok/rules/style.md': 'rule'
|
|
})
|
|
await vfs.mkdir('/home/guest/proj/src')
|
|
await vfs.mkdir('/home/guest/proj/.grok/rules')
|
|
const found = await s.bareAgentDiscoverAgentsMdPaths(
|
|
{ vfs, b4a },
|
|
'/home/guest/proj/src',
|
|
8
|
|
)
|
|
t.ok(found.includes('/home/guest/proj/src/AGENTS.md'))
|
|
t.ok(found.includes('/home/guest/proj/AGENTS.md'))
|
|
t.ok(found.includes('/home/guest/proj/.grok/rules/style.md'))
|
|
})
|
|
|
|
test('dispatch glob_files / todo_write / plan_mode / unique edit', async (t) => {
|
|
const s = loadDispatch()
|
|
const { vfs, files, b4a } = makeVfs({
|
|
'/home/guest/src/a.js': 'const x = 1\nconst y = 1\n',
|
|
'/home/guest/src/b.md': '# hi\n',
|
|
'/home/guest/.agent/workspace/memory/notes.md': 'remember holesail keys',
|
|
'/home/guest/.agent/workspace/MEMORY.md': 'top memory holesail'
|
|
})
|
|
await vfs.mkdir('/home/guest/src')
|
|
await vfs.mkdir('/home/guest/.agent')
|
|
await vfs.mkdir('/home/guest/.agent/workspace')
|
|
await vfs.mkdir('/home/guest/.agent/workspace/memory')
|
|
const ctx = { vfs, b4a }
|
|
const paths = {
|
|
dir: '/home/guest/.agent',
|
|
config: '/home/guest/.agent/config.json',
|
|
todos: '/home/guest/.agent/todos.json',
|
|
plan: '/home/guest/.agent/plan.md',
|
|
hooks: '/home/guest/.agent/hooks',
|
|
ask: '/home/guest/.agent/ask.json',
|
|
workspace: '/home/guest/.agent/workspace',
|
|
workspaceMemory: '/home/guest/.agent/workspace/memory',
|
|
compact: '/home/guest/.agent/compact.md'
|
|
}
|
|
const configRef = { current: { plan_mode_active: false } }
|
|
|
|
const globbed = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'glob_files',
|
|
args: { pattern: '**/*.js', root: '/home/guest' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(globbed.ok)
|
|
t.ok(globbed.files.includes('/home/guest/src/a.js'))
|
|
|
|
const todos = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'todo_write',
|
|
args: {
|
|
merge: false,
|
|
todos: [{ id: 'g1', content: 'port glob', status: 'in_progress' }]
|
|
},
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(todos.ok)
|
|
t.is(todos.summary.open, 1)
|
|
t.ok(String(files.get(paths.todos) || '').includes('port glob'))
|
|
|
|
const mem = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'memory_search',
|
|
args: { query: 'holesail' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(mem.ok)
|
|
t.ok(Array.isArray(mem.hits) && mem.hits.length >= 1)
|
|
|
|
configRef.current.plan_mode_active = true
|
|
const denied = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'write_file',
|
|
args: { path: '/home/guest/src/a.js', content: 'nope' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.absent(denied.ok)
|
|
t.is(denied.error, 'plan_mode_readonly')
|
|
|
|
const planned = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'enter_plan_mode',
|
|
args: { note: 'draft first' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(planned.ok)
|
|
t.ok(String(files.get(paths.plan) || '').includes('draft first'))
|
|
|
|
const exited = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'exit_plan_mode',
|
|
args: { summary: 'ready' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(exited.ok)
|
|
t.absent(configRef.current.plan_mode_active)
|
|
|
|
const uniqueFail = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'search_replace',
|
|
args: {
|
|
path: '/home/guest/src/a.js',
|
|
old_string: 'const',
|
|
new_string: 'let'
|
|
},
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.absent(uniqueFail.ok)
|
|
t.is(uniqueFail.error, 'old_string not unique')
|
|
|
|
const replaced = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'search_replace',
|
|
args: {
|
|
path: '/home/guest/src/a.js',
|
|
old_string: 'const x = 1',
|
|
new_string: 'const x = 2'
|
|
},
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(replaced.ok)
|
|
t.is(replaced.replacements, 1)
|
|
t.ok(String(files.get('/home/guest/src/a.js') || '').includes('const x = 2'))
|
|
|
|
const sliced = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'read_file',
|
|
args: { path: '/home/guest/src/a.js', offset: 1, limit: 1 },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(sliced.ok)
|
|
t.ok(String(sliced.content || '').startsWith('1→'))
|
|
})
|
|
|
|
test('grep finds lines with glob and context', async (t) => {
|
|
const s = loadPort()
|
|
const { vfs, b4a } = makeVfs({
|
|
'/home/guest/src/a.js': 'const alpha = 1\nconst beta = 2\nconst gamma = 3\n',
|
|
'/home/guest/src/b.md': '# alpha docs\n'
|
|
})
|
|
await vfs.mkdir('/home/guest/src')
|
|
const out = await s.bareAgentGrepFiles(
|
|
{ vfs, b4a },
|
|
{ pattern: 'alpha', root: '/home/guest', glob: '**/*.js', context: 1 }
|
|
)
|
|
t.ok(out.ok)
|
|
t.is(out.count, 1)
|
|
t.ok(out.matches[0].path.endsWith('a.js'))
|
|
t.ok(String(out.matches[0].text).includes('beta'))
|
|
})
|
|
|
|
test('apply_patch add/update/delete', async (t) => {
|
|
const s = loadPort()
|
|
const { vfs, files, b4a } = makeVfs({
|
|
'/home/guest/src/a.js': 'const x = 1\nconst y = 2\n'
|
|
})
|
|
await vfs.mkdir('/home/guest/src')
|
|
const parsed = s.bareAgentParseApplyPatch(
|
|
[
|
|
'*** Begin Patch',
|
|
'*** Add File: /home/guest/src/new.js',
|
|
'+export const n = 1',
|
|
'*** Update File: /home/guest/src/a.js',
|
|
'@@',
|
|
' const x = 1',
|
|
'-const y = 2',
|
|
'+const y = 3',
|
|
'*** Delete File: /home/guest/src/gone.js',
|
|
'*** End Patch'
|
|
].join('\n')
|
|
)
|
|
t.ok(parsed.ok)
|
|
t.is(parsed.ops.length, 3)
|
|
files.set('/home/guest/src/gone.js', 'bye')
|
|
const applied = await s.bareAgentApplyPatchOps({ vfs, b4a }, parsed.ops, {
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(applied.ok)
|
|
t.ok(String(files.get('/home/guest/src/new.js') || '').includes('export const n'))
|
|
t.ok(String(files.get('/home/guest/src/a.js') || '').includes('const y = 3'))
|
|
t.absent(files.has('/home/guest/src/gone.js'))
|
|
})
|
|
|
|
test('update_goal / grep dispatch', async (t) => {
|
|
const s = loadDispatch()
|
|
const { vfs, b4a } = makeVfs({
|
|
'/home/guest/src/a.js': 'hello world\n'
|
|
})
|
|
await vfs.mkdir('/home/guest/src')
|
|
await vfs.mkdir('/home/guest/.agent')
|
|
const ctx = { vfs, b4a }
|
|
const paths = {
|
|
dir: '/home/guest/.agent',
|
|
config: '/home/guest/.agent/config.json',
|
|
todos: '/home/guest/.agent/todos.json',
|
|
plan: '/home/guest/.agent/plan.md'
|
|
}
|
|
const configRef = { current: { autonomous_active: true, autonomous_status: 'running' } }
|
|
const grepped = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'grep',
|
|
args: { pattern: 'hello', path: '/home/guest' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(grepped.ok)
|
|
t.ok(grepped.count >= 1)
|
|
const goal = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'update_goal',
|
|
args: { completed: true, message: 'shipped' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(goal.ok)
|
|
t.is(configRef.current.autonomous_status, 'completed')
|
|
t.absent(configRef.current.autonomous_active)
|
|
})
|
|
|
|
test('schedule interval parse and memory_append', async (t) => {
|
|
const s = loadPort()
|
|
const five = s.bareAgentParseScheduleInterval('5m')
|
|
t.is(five.kind, 'everyMs')
|
|
t.is(five.everyMs, 300000)
|
|
const cron = s.bareAgentParseScheduleInterval('0 * * * *')
|
|
t.is(cron.kind, 'calendar')
|
|
t.is(s.bareAgentScheduleId('Ping Host'), 'agent-ping-host')
|
|
|
|
const d = loadDispatch()
|
|
const { vfs, files, b4a } = makeVfs({})
|
|
await vfs.mkdir('/home/guest/.agent/workspace')
|
|
const ctx = { vfs, b4a }
|
|
const paths = {
|
|
dir: '/home/guest/.agent',
|
|
workspace: '/home/guest/.agent/workspace',
|
|
workspaceMemory: '/home/guest/.agent/workspace/memory'
|
|
}
|
|
const mem = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'memory_append',
|
|
args: { text: 'holesail keys live in state.json', kind: 'FACT' },
|
|
configRef: { current: {} },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(mem.ok)
|
|
t.ok(String(files.get(paths.workspace + '/MEMORY.md') || '').includes('FACT'))
|
|
|
|
const sched = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'schedule_task',
|
|
args: { id: 'heartbeat', interval: '30m', prompt: 'report peer count' },
|
|
configRef: { current: {} },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(sched.ok)
|
|
t.ok(String(files.get(sched.path) || '').includes('EveryMs=1800000'))
|
|
t.ok(String(files.get(sched.path) || '').includes('agent --auto'))
|
|
})
|
|
|
|
test('gitignore, fuzzy, undo, history search', async (t) => {
|
|
const s = loadPort()
|
|
const rules = s.bareAgentParseIgnoreRules('*.log\n!keep.log\nbuild/\n')
|
|
t.ok(s.bareAgentIgnoreMatch('foo.log', false, rules))
|
|
t.absent(s.bareAgentIgnoreMatch('keep.log', false, rules))
|
|
t.ok(s.bareAgentIgnoreMatch('build/out.js', true, rules) || s.bareAgentIgnoreMatch('build', true, rules))
|
|
t.ok(s.bareAgentFuzzyScore('readme', '/home/x/README.md') > s.bareAgentFuzzyScore('readme', '/home/x/a.js'))
|
|
const hits = s.bareAgentHistorySearch(
|
|
[
|
|
{ role: 'user', content: 'fix the holesail tunnel' },
|
|
{ role: 'assistant', content: 'ok' }
|
|
],
|
|
'holesail',
|
|
4
|
|
)
|
|
t.ok(hits.length >= 1)
|
|
t.is(hits[0].role, 'user')
|
|
|
|
const d = loadDispatch()
|
|
const { vfs, files, b4a } = makeVfs({
|
|
'/home/guest/src/keep.js': 'const keep = 1\n',
|
|
'/home/guest/src/skip.log': 'noise\n',
|
|
'/home/guest/.gitignore': '*.log\n'
|
|
})
|
|
await vfs.mkdir('/home/guest/src')
|
|
await vfs.mkdir('/home/guest/.agent')
|
|
const ctx = { vfs, b4a }
|
|
const paths = {
|
|
dir: '/home/guest/.agent',
|
|
edits: '/home/guest/.agent/edits.json'
|
|
}
|
|
const globbed = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'glob_files',
|
|
args: { pattern: '**/*', root: '/home/guest' },
|
|
configRef: { current: {} },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(globbed.ok)
|
|
t.ok(globbed.files.some((p) => /keep\.js$/.test(p)))
|
|
t.absent(globbed.files.some((p) => /skip\.log$/.test(p)))
|
|
|
|
const wrote = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'write_file',
|
|
args: { path: '/home/guest/src/keep.js', content: 'changed\n' },
|
|
configRef: { current: {} },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(wrote.ok)
|
|
t.is(files.get('/home/guest/src/keep.js'), 'changed\n')
|
|
const undone = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'undo_last_edit',
|
|
args: {},
|
|
configRef: { current: {} },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(undone.ok)
|
|
t.ok(String(files.get('/home/guest/src/keep.js') || '').includes('const keep'))
|
|
|
|
const waited = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'wait_for',
|
|
args: { path: '/home/guest/src/keep.js', pattern: 'keep', timeout_ms: 500, interval_ms: 100 },
|
|
configRef: { current: {} },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(waited.ok)
|
|
t.ok(waited.matched)
|
|
})
|
|
|
|
test('rewind, export, unified diff, symbol regex', async (t) => {
|
|
const s = loadPort()
|
|
const hist = [
|
|
{ role: 'user', content: 'first' },
|
|
{ role: 'assistant', content: 'ok1' },
|
|
{ role: 'user', content: 'second' },
|
|
{ role: 'assistant', content: 'ok2' }
|
|
]
|
|
const points = s.bareAgentRewindPoints(hist)
|
|
t.is(points.length, 2)
|
|
const rewound = s.bareAgentRewindHistory(hist, { steps: 1 })
|
|
t.ok(rewound.ok)
|
|
t.is(rewound.dropped, 2)
|
|
t.is(rewound.messages.length, 2)
|
|
t.is(rewound.messages[0].content, 'first')
|
|
const md = s.bareAgentExportTranscript(hist)
|
|
t.ok(md.includes('# Agent session export'))
|
|
t.ok(md.includes('first'))
|
|
const diff = s.bareAgentUnifiedDiff('a\nb\nc\n', 'a\nB\nc\n', { from: '/a', to: '/b' })
|
|
t.ok(diff.ok)
|
|
t.absent(diff.identical)
|
|
t.ok(String(diff.text).includes('-b'))
|
|
t.ok(String(diff.text).includes('+B'))
|
|
const re = new RegExp(s.bareAgentSymbolRegex('bareAgentCopyPath'))
|
|
t.ok(re.test('async function bareAgentCopyPath(ctx, from, to) {'))
|
|
t.absent(re.test('const x = bareAgentCopyPath'))
|
|
})
|
|
|
|
test('copy, tree, find_symbol, create_skill, rewind dispatch', async (t) => {
|
|
const d = loadDispatch()
|
|
const { vfs, files, b4a } = makeVfs({
|
|
'/home/guest/src/lib.js': 'async function helloWorld() {\n return 1\n}\n',
|
|
'/home/guest/src/a.txt': 'alpha\n',
|
|
'/home/guest/src/b.txt': 'beta\n',
|
|
'/home/guest/.agent/history.json': JSON.stringify(
|
|
[
|
|
{ role: 'user', content: 'one' },
|
|
{ role: 'assistant', content: 'ok' },
|
|
{ role: 'user', content: 'two' },
|
|
{ role: 'assistant', content: 'ok2' }
|
|
],
|
|
null,
|
|
2
|
|
)
|
|
})
|
|
await vfs.mkdir('/home/guest/src')
|
|
await vfs.mkdir('/home/guest/.agent')
|
|
const ctx = { vfs, b4a }
|
|
const paths = {
|
|
dir: '/home/guest/.agent',
|
|
history: '/home/guest/.agent/history.json',
|
|
workspace: '/home/guest/.agent/workspace',
|
|
workspaceSkills: '/home/guest/.agent/workspace/skills',
|
|
workspaceMemory: '/home/guest/.agent/workspace/memory'
|
|
}
|
|
|
|
const copied = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'copy_path',
|
|
args: { from_path: '/home/guest/src/a.txt', to_path: '/home/guest/src/a.copy.txt' },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(copied.ok)
|
|
t.is(files.get('/home/guest/src/a.copy.txt'), 'alpha\n')
|
|
|
|
const tree = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'list_directory',
|
|
args: { path: '/home/guest/src', tree: true },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(tree.ok)
|
|
t.ok(String(tree.tree || '').includes('lib.js'))
|
|
|
|
const found = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'find_symbol',
|
|
args: { name: 'helloWorld', root: '/home/guest/src' },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(found.ok)
|
|
t.ok(found.count >= 1)
|
|
|
|
const counted = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'grep',
|
|
args: { pattern: 'hello', root: '/home/guest/src', output_mode: 'count' },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(counted.ok)
|
|
t.is(counted.output_mode, 'count')
|
|
t.ok(counted.count >= 1)
|
|
|
|
const skill = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'create_skill',
|
|
args: { id: 'demo-skill', description: 'Demo', body: '# Demo\n\nDo the thing.\n' },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(skill.ok)
|
|
t.ok(String(files.get('/home/guest/.agent/workspace/skills/demo-skill/SKILL.md') || '').includes('name: demo-skill'))
|
|
|
|
const rewound = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'rewind_session',
|
|
args: { steps: 1 },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(rewound.ok)
|
|
t.is(rewound.dropped, 2)
|
|
const hist = JSON.parse(files.get('/home/guest/.agent/history.json') || '[]')
|
|
t.is(hist.length, 2)
|
|
|
|
const remembered = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'remember',
|
|
args: { text: 'holesail keys live in ~/.holesail' },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(remembered.ok)
|
|
t.is(remembered.kind, 'FACT')
|
|
t.ok(String(files.get('/home/guest/.agent/workspace/MEMORY.md') || '').includes('holesail keys'))
|
|
|
|
const exported = await dispatch(d, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'export_session',
|
|
args: { path: '/home/guest/.agent/export.md' },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(exported.ok)
|
|
t.ok(String(files.get('/home/guest/.agent/export.md') || '').includes('# Agent session export'))
|
|
})
|
|
|
|
test('copy from read-only system path and project skill roots', async (t) => {
|
|
const d = loadDispatch()
|
|
const { vfs, files, b4a } = makeVfs({
|
|
'/bin/sh': 'readonly-shell\n',
|
|
'/home/guest/proj/.grok/skills/demo/SKILL.md':
|
|
'---\nname: demo\ndescription: Project skill\n---\n\n# Demo\n'
|
|
})
|
|
await vfs.mkdir('/bin')
|
|
await vfs.mkdir('/home/guest')
|
|
const ctx = { vfs, b4a, env: { PWD: '/home/guest/proj', HOME: '/home/guest' } }
|
|
const copied = await dispatch(d, {
|
|
ctx,
|
|
paths: { dir: '/home/guest/.agent', workspace: '/home/guest/.agent/workspace' },
|
|
toolName: 'copy_path',
|
|
args: { from_path: '/bin/sh', to_path: '/home/guest/sh.copy' },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(copied.ok)
|
|
t.is(files.get('/home/guest/sh.copy'), 'readonly-shell\n')
|
|
|
|
const blocked = await dispatch(d, {
|
|
ctx,
|
|
paths: { dir: '/home/guest/.agent' },
|
|
toolName: 'copy_path',
|
|
args: { from_path: '/home/guest/sh.copy', to_path: '/bin/sh.hijack' },
|
|
home: '/home/guest'
|
|
})
|
|
t.absent(blocked.ok)
|
|
t.is(blocked.error, 'path_not_allowed')
|
|
|
|
const s = loadPort()
|
|
const roots = await s.bareAgentDiscoverProjectSkillRoots(ctx, '/home/guest/proj/src')
|
|
t.ok(roots.some((r) => String(r.path || '').endsWith('/.grok/skills')))
|
|
})
|
|
|
|
test('search result parser extracts DDG-style topics', async (t) => {
|
|
const s = loadPort()
|
|
const rows = s.bareAgentParseSearchResults(
|
|
{
|
|
Heading: 'Bare',
|
|
AbstractURL: 'https://example.com/bare',
|
|
Abstract: 'A JS runtime',
|
|
RelatedTopics: [
|
|
{ Text: 'Pear', FirstURL: 'https://example.com/pear' },
|
|
{ Topics: [{ Text: 'Holepunch', FirstURL: 'https://example.com/hp' }] }
|
|
]
|
|
},
|
|
8
|
|
)
|
|
t.ok(rows.length >= 2)
|
|
t.ok(rows.some((r) => r.url === 'https://example.com/bare'))
|
|
})
|
|
|
|
test('dispatch delete / proc / run_command are open by default', async (t) => {
|
|
const s = loadDispatch()
|
|
const { vfs, files, b4a } = makeVfs({
|
|
'/home/guest/wipe-me.txt': 'gone soon',
|
|
'/bin/sh': 'readonly',
|
|
'/proc/bare_os/extra.json': '{"ok":true}'
|
|
})
|
|
await vfs.mkdir('/home/guest')
|
|
const ctx = { vfs, b4a }
|
|
const paths = {
|
|
dir: '/home/guest/.agent',
|
|
config: '/home/guest/.agent/config.json',
|
|
cmdOut: '/tmp/agent.out'
|
|
}
|
|
const configRef = {
|
|
current: {
|
|
allow_delete: true,
|
|
command_deny: [],
|
|
autonomous_active: true,
|
|
autonomous_allow_paths: ['*'],
|
|
autonomous_deny_ops: []
|
|
}
|
|
}
|
|
|
|
const del = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'delete_path',
|
|
args: { path: '/home/guest/wipe-me.txt' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(del.ok)
|
|
t.absent(files.has('/home/guest/wipe-me.txt'))
|
|
|
|
const deniedSys = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'write_file',
|
|
args: { path: '/bin/sh', content: 'nope' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.absent(deniedSys.ok)
|
|
t.is(deniedSys.error, 'path_not_allowed')
|
|
|
|
const proc = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'read_proc_file',
|
|
args: { path: '/proc/bare_os/extra.json' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(proc.ok)
|
|
t.ok(proc.json && proc.json.ok === true)
|
|
|
|
const seen = []
|
|
ctx.execLine = async (cmd) => {
|
|
seen.push(String(cmd))
|
|
await vfs.writeFile('/tmp/agent.out', b4a.from('ok\n'))
|
|
}
|
|
const run = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'run_command',
|
|
args: { command: 'git status && rm -rf /tmp/x' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(run.ok)
|
|
t.ok(seen.length >= 1)
|
|
|
|
configRef.current.command_deny = ['rm -rf']
|
|
const blocked = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'run_command',
|
|
args: { command: 'rm -rf /tmp/x' },
|
|
configRef,
|
|
home: '/home/guest'
|
|
})
|
|
t.absent(blocked.ok)
|
|
t.is(blocked.error, 'command_denied')
|
|
})
|
|
|
|
test('discord_send_message queues or uses the live DM hook', async (t) => {
|
|
const s = loadDispatch()
|
|
const { vfs, files, b4a } = makeVfs({})
|
|
await vfs.writeFile(
|
|
'~/.discord/channel.json',
|
|
b4a.from(JSON.stringify({ userId: '111', open: true }) + '\n')
|
|
)
|
|
const paths = {
|
|
dir: '/home/guest/.agent',
|
|
config: '/home/guest/.agent/config.json',
|
|
cmdOut: '/tmp/agent.out'
|
|
}
|
|
const queued = await dispatch(s, {
|
|
ctx: { vfs, b4a },
|
|
paths,
|
|
toolName: 'discord_send_message',
|
|
args: { text: 'hello from agent' },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(queued.ok)
|
|
t.ok(queued.queued)
|
|
t.ok(files.has('~/.discord/outbox.json'))
|
|
t.ok(String(files.get('~/.discord/outbox.json')).includes('hello from agent'))
|
|
|
|
const sent = []
|
|
const live = await dispatch(s, {
|
|
ctx: {
|
|
vfs,
|
|
b4a,
|
|
bareOsDiscordSendDm: async (opts) => {
|
|
sent.push(opts)
|
|
return { ok: true, queued: false, userId: '111' }
|
|
}
|
|
},
|
|
paths,
|
|
toolName: 'discord_send_message',
|
|
args: { text: 'live ping' },
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(live.ok)
|
|
t.absent(live.queued)
|
|
t.is(sent[0].text, 'live ping')
|
|
|
|
const empty = await dispatch(s, {
|
|
ctx: { vfs, b4a },
|
|
paths,
|
|
toolName: 'discord_send_message',
|
|
args: { text: ' ' },
|
|
home: '/home/guest'
|
|
})
|
|
t.absent(empty.ok)
|
|
t.is(empty.error, 'empty_message')
|
|
|
|
const st = await dispatch(s, {
|
|
ctx: { vfs, b4a },
|
|
paths,
|
|
toolName: 'discord_channel_status',
|
|
args: {},
|
|
home: '/home/guest'
|
|
})
|
|
t.ok(st.ok)
|
|
t.is(st.userId, '111')
|
|
})
|
|
|
|
test('write_file and create_directory expand ~/ onto the session home', async (t) => {
|
|
const s = loadDispatch()
|
|
const home = '/home/e16d7bc4ce14'
|
|
const { vfs, files, dirs, b4a } = makeVfs({})
|
|
const ctx = { vfs, b4a }
|
|
const paths = {
|
|
dir: home + '/.agent',
|
|
config: home + '/.agent/config.json',
|
|
cmdOut: home + '/.agent/last_command_out.txt',
|
|
edits: home + '/.agent/edits.json'
|
|
}
|
|
const configRef = { current: {} }
|
|
|
|
const mkdir = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'create_directory',
|
|
args: { path: '~/test_work' },
|
|
configRef,
|
|
home
|
|
})
|
|
t.ok(mkdir.ok)
|
|
t.is(mkdir.path, home + '/test_work')
|
|
t.ok(dirs.has(home + '/test_work'))
|
|
|
|
const wrote = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'write_file',
|
|
args: { path: '~/test_work/hello.js', content: 'console.log("hi")\n' },
|
|
configRef,
|
|
home
|
|
})
|
|
t.ok(wrote.ok)
|
|
t.is(wrote.path, home + '/test_work/hello.js')
|
|
t.is(files.get(home + '/test_work/hello.js'), 'console.log("hi")\n')
|
|
|
|
const alias = await dispatch(s, {
|
|
ctx,
|
|
paths,
|
|
toolName: 'write_file',
|
|
args: { file_path: '~/test_work/welcome.js', contents: 'ok\n' },
|
|
configRef,
|
|
home
|
|
})
|
|
t.ok(alias.ok)
|
|
t.is(alias.path, home + '/test_work/welcome.js')
|
|
t.is(files.get(home + '/test_work/welcome.js'), 'ok\n')
|
|
})
|