Files
lms-fe/scripts/test-agent-tools.cjs

223 lines
14 KiB
JavaScript

const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
const os = require('node:os')
const { spawnSync } = require('node:child_process')
const ts = require('typescript')
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(require('node:fs').readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, filename)
const { validateAgentTool, validateAgentOperation, safeAgentPath } = require('../src/shared/agentTools.ts')
const { runAgentFileTool, applyAgentFiles, agentFileHash } = require('../src/main/agentFileTools.ts')
const { agentRemoteFilesScript } = require('../src/main/agentRemoteFiles.ts')
const { inspectAgentRuntime, agentRosProbePython } = require('../src/main/agentRuntimeTools.ts')
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
const plain = value => JSON.parse(JSON.stringify(value))
const request = (name, args = {}) => ({ id: 'query-1', name, moduleName: 'NAV2-MKMini', args })
function remote(input) {
const result = spawnSync('bash', ['--noprofile', '--norc', '-s'], { input: agentRemoteFilesScript(input), encoding: 'utf8', maxBuffer: 4_000_000, timeout: 10_000 })
if (result.error || result.status !== 0) throw new Error(result.stderr || result.error?.message)
return JSON.parse(result.stdout)
}
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-agent-tools-'))
t.after(() => fs.rm(root, { recursive: true, force: true }))
await fs.mkdir(path.join(root, 'config'))
await fs.writeFile(path.join(root, 'config', 'sensor.yaml'), 'host_ip: 192.168.2.88\n조향: ackermann\n')
await fs.writeFile(path.join(root, 'CMakeLists.txt'), 'project(vehicle)\n')
return root
}
test('tool requests reject writes, extra shell arguments, unsafe paths and unbounded observations', () => {
for (const name of ['shell', 'files.write', 'network.set', 'ros.publish']) assert.throws(() => validateAgentTool(request(name)))
for (const name of ['files.list', 'files.read', 'files.search']) {
for (const p of ['../secret', '/etc/passwd', '.env', 'config/.env', 'build/secret.py', 'config/id_rsa', 'config/key.pem', 'config\\x.py', 'a//b']) assert.throws(() => validateAgentTool(request(name, { path: p, ...(name === 'files.search' ? { query: 'ip' } : {}) })))
}
assert.throws(() => validateAgentTool(request('network.inspect', { command: 'ip addr replace' })))
assert.throws(() => validateAgentTool(request('ros.inspect', { topics: ['/scan; reboot'] })))
assert.throws(() => validateAgentTool(request('files.read', { path: 'a.py', lineCount: 401 })))
assert.throws(() => validateAgentOperation({ name: 'modules.stop' }))
assert.throws(() => validateAgentOperation({ name: 'practice.prepare', profile: 'guess' }))
assert.equal(validateAgentOperation({ name: 'practice.prepare', profile: 'mkmini-3d' }).name, 'practice.prepare')
assert.equal(safeAgentPath('src/launch/vehicle.launch.py'), 'src/launch/vehicle.launch.py')
})
for (const backend of ['local', 'remote']) {
const read = async (root, tool) => backend === 'local' ? runAgentFileTool(root, validateAgentTool(tool)) : remote({ root, request: validateAgentTool(tool) })
const apply = changes => backend === 'local' ? applyAgentFiles(changes) : Promise.resolve().then(() => remote({ changes }))
test(`${backend}: lists and searches sources without hidden, generated or symlinked data`, async t => {
const root = await fixture(t)
await fs.writeFile(path.join(root, '.env'), 'host_ip: PRIVATE')
await fs.mkdir(path.join(root, 'build'))
await fs.writeFile(path.join(root, 'build', 'generated.yaml'), 'host_ip: PRIVATE')
await fs.symlink('/etc/passwd', path.join(root, 'config', 'outside.txt'))
const listing = await read(root, request('files.list'))
assert.deepEqual([...listing.data.paths].sort(), ['CMakeLists.txt', 'config/sensor.yaml'])
const search = await read(root, request('files.search', { query: 'HOST_IP' }))
assert.equal(search.data.matches.length, 1)
assert.equal(search.data.matches[0].path, 'config/sensor.yaml')
assert.equal(search.data.matches[0].line, 1)
await assert.rejects(read(root, request('files.read', { path: 'config/outside.txt' })))
})
test(`${backend}: full file reads include a reproducible hash, partial and missing reads are explicit`, async t => {
const root = await fixture(t)
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' }))
assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
assert.equal(full.data.complete, true)
const rangedFull = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 1, lineCount: 200 }))
assert.equal(rangedFull.data.complete, true)
assert.equal(rangedFull.truncated, false)
assert.equal(rangedFull.data.endLine, 3)
assert.equal(rangedFull.data.contentTruncated, false)
const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 }))
assert.equal(partial.data.content, '조향: ackermann')
assert.equal(partial.data.complete, false)
assert.equal(partial.truncated, true)
assert.equal(partial.data.endLine, 2)
assert.equal(partial.data.contentTruncated, false)
const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
assert.equal(missing.data.exists, false)
assert.equal(missing.data.hash, null)
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' })))
})
test(`${backend}: character truncation never advertises a cut line as fully observed`, async t => {
const root = await fixture(t)
await fs.writeFile(path.join(root, 'config/long.yaml'), `first: true\n${'x'.repeat(40_000)}\nlast: true`)
const readout = await read(root, request('files.read', { path: 'config/long.yaml', startLine: 1, lineCount: 3 }))
assert.equal(readout.data.complete, false)
assert.equal(readout.data.contentTruncated, true)
assert.equal(readout.data.endLine, 1)
})
test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
const root = await fixture(t)
await fs.mkdir(path.join(root, 'code'))
for (let i = 0; i < 5; i++) await fs.writeFile(path.join(root, 'code', `large${i}.py`), 'x'.repeat(900_000))
const result = await read(root, request('files.search', { query: 'host_ip', path: 'config' }))
assert.equal(result.truncated, false)
assert.equal(result.data.matches[0].path, 'config/sensor.yaml')
})
test(`${backend}: validates every original before changing any file and preserves permissions`, async t => {
const root = await fixture(t)
const original = await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')
const first = { root, moduleName: 'NAV2-MKMini', path: 'config/sensor.yaml', content: 'host_ip: 192.168.1.88\n', expectedHash: agentFileHash(original) }
const stale = { root, moduleName: 'NAV2-MKMini', path: 'CMakeLists.txt', content: 'changed', expectedHash: '0'.repeat(64) }
await assert.rejects(apply([first, stale]))
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), original)
await fs.chmod(path.join(root, first.path), 0o664)
await apply([first, { root, moduleName: 'NAV2-MKMini', path: 'config/new.yaml', content: 'created: true', expectedHash: null }])
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), first.content)
assert.equal((await fs.stat(path.join(root, first.path))).mode & 0o777, 0o664)
assert.equal(await fs.readFile(path.join(root, 'config/new.yaml'), 'utf8'), 'created: true')
await assert.rejects(apply([first]))
})
}
test('remote batch streams large content rather than putting it into command arguments', async t => {
const root = await fixture(t)
const content = 'large YAML value '.repeat(11_000)
remote({ changes: [{ root, moduleName: 'NAV2-MKMini', path: 'config/large.yaml', expectedHash: null, content }] })
assert.equal(await fs.readFile(path.join(root, 'config/large.yaml'), 'utf8'), content)
})
test('local batch rolls back earlier replacements if a later replacement fails', async t => {
const root = await fixture(t)
const rename = fs.rename
let calls = 0
fs.rename = async (...args) => { if (++calls === 2) throw new Error('injected rename error'); return rename(...args) }
try {
await assert.rejects(applyAgentFiles([
{ root, moduleName: 'M', path: 'config/new.yaml', expectedHash: null, content: 'new' },
{ root, moduleName: 'M', path: 'CMakeLists.txt', expectedHash: agentFileHash('project(vehicle)\n'), content: 'changed' }
]), /injected/)
await assert.rejects(fs.stat(path.join(root, 'config/new.yaml')), { code: 'ENOENT' })
assert.equal(await fs.readFile(path.join(root, 'CMakeLists.txt'), 'utf8'), 'project(vehicle)\n')
} finally { fs.rename = rename }
})
test('network inspection keeps real host and container observations separate', async () => {
const calls = []
const result = await inspectAgentRuntime(request('network.inspect'), async (executable, args) => { calls.push([executable, args]); return '[{"ifname":"eth0"}]' }, async command => { calls.push(command); throw new Error('container is stopped') })
assert.equal(result.host.interfaces[0].ifname, 'eth0')
assert.match(result.container.error, /stopped/)
assert.equal(calls[0][0], 'ip')
assert.equal(calls[1][0], 'ip')
assert.equal(calls[2], 'ip -j address show')
})
test('ROS probe samples retained maps with matching QoS and distinguishes empty sensor payloads', () => {
const preamble = String.raw`
import sys, types, time
NS = types.SimpleNamespace
tick = [0]
def monotonic():
tick[0] += 1
return tick[0]
time.monotonic = monotonic
class Node:
def get_topic_names_and_types(self):
return [(topic, ['test/Message']) for topic in ['/map', '/scan', '/tf_static']]
def get_publishers_info_by_topic(self, topic):
return [NS(qos_profile=NS(durability='retained' if topic in ['/map', '/tf_static'] else 'volatile'))]
def create_subscription(self, message_type, topic, callback, qos):
if topic in ['/map', '/tf_static'] and qos.durability != 'retained':
raise ValueError('wrong durability')
if topic == '/map':
callback(NS(info=NS(width=10, height=10), data=[]))
elif topic == '/scan':
callback(NS(ranges=[1, 2, 3]))
else:
callback(NS(transforms=[NS(header=NS(frame_id='map'), child_frame_id='odom')]))
return object()
def count_publishers(self, topic):
return 1
def get_node_names(self):
return ['probe']
def destroy_node(self):
pass
sys.modules['rclpy'] = NS(init=lambda **kw: None, create_node=lambda name: Node(), spin_once=lambda *a, **kw: None, shutdown=lambda: None)
sys.modules['rclpy.qos'] = NS(QoSProfile=lambda **kw: NS(**kw), ReliabilityPolicy=NS(BEST_EFFORT='best-effort'), DurabilityPolicy=NS(TRANSIENT_LOCAL='retained', VOLATILE='volatile'))
sys.modules['rosidl_runtime_py'] = types.ModuleType('rosidl_runtime_py')
sys.modules['rosidl_runtime_py.utilities'] = NS(get_message=lambda name: object)
`
const run = spawnSync('python3', ['-c', `${preamble}\nexec(${JSON.stringify(agentRosProbePython)})`, Buffer.from(JSON.stringify(['/map', '/scan', '/tf_static'])).toString('base64')], { encoding: 'utf8', timeout: 5000 })
assert.equal(run.status, 0, run.stderr)
const data = JSON.parse(run.stdout)
assert.equal(data.topics.find(topic => topic.name === '/map').samples, 1)
assert.equal(data.topics.find(topic => topic.name === '/map').validSamples, 0)
assert.equal(data.topics.find(topic => topic.name === '/scan').validSamples, 1)
assert.deepEqual(data.tfEdges, [{ parent: 'map', child: 'odom' }])
})
test('restoring an interrupted action does not leave history permanently running', () => {
const { recoverInterruptedAgentMessages } = require('../src/renderer/src/features/workspace/agentContext.ts')
const messages = [{ id: 'reply', role: 'assistant', content: 'result', applyState: 'applying', commandExecutions: { start: { status: 'running', output: 'started one module' }, build: { status: 'completed' } } }]
const restored = recoverInterruptedAgentMessages(messages)
assert.equal(restored[0].applyState, 'error')
assert.equal(restored[0].commandExecutions.start.status, 'failed')
assert.equal(restored[0].commandExecutions.start.output, 'started one module')
assert.equal(restored[0].commandExecutions.build.status, 'completed')
assert.equal(messages[0].commandExecutions.start.status, 'running')
})
test('tool loop sends actual evidence, deduplicates requests despite new IDs and never runs actions', async () => {
let calls = 0
const executed = []
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
executeTool: async tool => { executed.push(tool); return { id: tool.id, name: tool.name, status: 'ok', target: 'test', observedAt: new Date().toISOString(), data: '{"paths":["config/a.yaml"]}', truncated: false } },
requestPlan: async (context, finalize) => {
calls++
if (calls === 2) assert.equal(context.toolResults.length, 1)
return { provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'explanation', requestedModules: [], requestedLogs: [], actionPlan: [], commands: finalize ? [{ operation: { name: 'modules.start' } }] : [], changes: [], createModule: null, toolRequests: [request('files.list', {})] } }
}
})
assert.equal(executed.length, 1)
assert.equal(calls, 3)
assert.deepEqual(plain(result.plan.commands), [])
assert.deepEqual(plain(result.plan.toolRequests), [])
assert.match(result.plan.explanation, /files.list: ok/)
})
test('backend and desktop share exactly the same tool validation contract', async () => {
assert.equal(await fs.readFile(path.resolve(__dirname, '../../lms-be/src/ai/agent-tools.ts'), 'utf8'), await fs.readFile(path.resolve(__dirname, '../src/shared/agentTools.ts'), 'utf8'))
})