Files
lms-fe/scripts/test-agent-context.cjs
T

372 lines
18 KiB
JavaScript

const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const output = {}
const evidenceApi = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: evidenceApi })
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const { resolveAgentContext } = output
const plain = value => JSON.parse(JSON.stringify(value))
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
const response = (requestedModules = [], requestedLogs = []) => ({
provider: 'QWEN', model: 'test', usage: { totalTokens: 10 },
plan: { summary: 'result', explanation: 'analysis', requestedModules, requestedLogs, actionPlan: [], commands: [], changes: [], createModule: null }
})
const options = overrides => ({
initialContext: { modules: [moduleInfo('MKMini'), moduleInfo('Control')], runtimeLogs: [] },
signal: new AbortController().signal,
...overrides
})
test('continues MKMini -> Control -> final analysis and retains earlier source files', async () => {
const inputs = []
const loads = []
const result = await resolveAgentContext(options({
requestPlan: async context => {
inputs.push(plain(context))
return [response(['MKMini']), response(['Control']), response()][inputs.length - 1]
},
loadRequestedContext: async request => {
loads.push(plain(request))
return { modules: ['MKMini', 'Control'].map(name => moduleInfo(name, request.modules.includes(name) ? [{ path: `${name}.py`, content: `${name} source` }] : [])), runtimeLogs: [] }
}
}))
assert.equal(inputs.length, 3)
assert.deepEqual(loads, [{ modules: ['MKMini'], logs: [] }, { modules: ['Control'], logs: [] }])
assert.equal(inputs[2].modules[0].files[0].content, 'MKMini source')
assert.equal(inputs[2].modules[1].files[0].content, 'Control source')
assert.deepEqual(plain(result.plan.requestedModules), [])
assert.equal(result.usage.totalTokens, 30)
})
test('keeps logs while loading a later source module', async () => {
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
calls += 1
if (calls === 3) assert.equal(context.runtimeLogs[0].content, 'error log')
return [response([], ['Control']), response(['Control']), response()][calls - 1]
},
loadRequestedContext: async request => ({
modules: [moduleInfo('Control', request.modules.length ? [{ path: 'node.py', content: 'code' }] : [])],
runtimeLogs: request.logs.length ? [{ moduleName: 'Control', state: 'running', content: 'error log' }] : []
})
}))
assert.equal(calls, 3)
})
test('makes one analysis-only completion when the model repeats a context request', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async (context, finalize) => {
calls++
if (!finalize) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').files[0].path, 'controller.py')
return { ...response(), plan: { ...response().plan, explanation: 'controller.py에서 조향 설정을 확인했습니다.' } }
},
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'steering = 1' }])], runtimeLogs: [] } }
}))
assert.equal(loads, 1)
assert.equal(calls, 3)
assert.match(result.plan.explanation, /조향 설정을 확인/)
assert.equal(result.usage.totalTokens, 30)
})
test('finishes honestly without an infinite loop if the model ignores the finalization request', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async () => { calls++; return response(['Control']) },
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
}))
assert.equal(loads, 1)
assert.equal(calls, 3)
assert.deepEqual(plain(result.plan.requestedModules), [])
assert.deepEqual(plain(result.plan.actionPlan), [])
assert.match(result.plan.explanation, /결론을 확정하지 못했습니다/)
assert.match(result.plan.explanation, /Control: 제공된 파일 0개/)
assert.equal(result.plan.createModule, null)
})
test('uses already provided source files without loading the same module again', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'steering = 1' }])], runtimeLogs: [] },
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) },
loadRequestedContext: async () => { loads++; return { modules: [], runtimeLogs: [] } }
}))
assert.equal(loads, 0)
assert.equal(calls, 2)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('recovery cannot turn repeated source requests into executable commands or edits', async () => {
const result = await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'source' }])], runtimeLogs: [] },
requestPlan: async (_, finalize) => finalize ? {
...response(),
plan: {
...response().plan, actionPlan: ['execute'], commands: [{ id: 'run', command: './run.sh' }],
changes: [{ moduleName: 'Control', path: 'control.py', content: 'changed' }], createModule: { name: 'new' }
}
} : response(['Control']),
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
}))
for (const field of ['commands', 'changes', 'actionPlan']) assert.deepEqual(plain(result.plan[field]), [])
assert.equal(result.plan.createModule, null)
})
test('does not publish a finalization response received after cancellation', async () => {
const controller = new AbortController()
let calls = 0
await assert.rejects(resolveAgentContext(options({
signal: controller.signal,
requestPlan: async (_, finalize) => {
calls++
if (finalize) controller.abort()
return finalize ? response() : response(['Control'])
}
})), { name: 'AbortError' })
assert.equal(calls, 2)
})
test('requests only new resources when the model also repeats an earlier module', async () => {
let count = 0
const loads = []
await resolveAgentContext(options({
requestPlan: async () => [response(['MKMini']), response(['MKMini', 'Control']), response()][count++],
loadRequestedContext: async request => { loads.push(plain(request)); return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
}))
assert.deepEqual(loads[1], { modules: ['Control'], logs: [] })
})
test('does not start another API call after cancellation during context loading', async () => {
const controller = new AbortController()
let calls = 0
await assert.rejects(resolveAgentContext(options({
signal: controller.signal,
requestPlan: async () => { calls += 1; return response(['Control']) },
loadRequestedContext: async () => { controller.abort(); return { modules: [], runtimeLogs: [] } }
})), { name: 'AbortError' })
assert.equal(calls, 1)
})
test('bounds the number of distinct automatic context requests', async () => {
let calls = 0
let loads = 0
const result = await resolveAgentContext(options({
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response([`Module${calls}`]) },
loadRequestedContext: async request => { loads++; return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
}))
assert.equal(calls, 7)
assert.equal(loads, 5)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('passes file-read failures to the model for a concrete final explanation', async () => {
let count = 0
await resolveAgentContext(options({
requestPlan: async context => {
if (count++ === 0) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').fileContext.status, 'error')
return response()
},
loadRequestedContext: async () => ({ modules: [{ ...moduleInfo('Control'), fileContext: { status: 'error', message: 'permission denied' } }], runtimeLogs: [] })
}))
})
test('excludes oversized source files with an explicit notice and continues with complete smaller files', async () => {
let calls = 0
const smallFile = { path: 'config/steering.yaml', content: 'kinematics: ackermann' }
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
const control = context.modules.find(module => module.name === 'Control')
assert.deepEqual(plain(control.files), [smallFile])
assert.equal(control.fileContext.status, 'limited')
assert.match(control.fileContext.message, /전체 2개 중 1개/)
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }, smallFile])], runtimeLogs: [] })
}))
assert.equal(calls, 2)
})
test('requests a final answer rather than leaving a context-only response when no loader is available', async () => {
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) }
}))
assert.equal(calls, 2)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('keeps a just-started module running when a stale collector supplies source files', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [{ ...moduleInfo('Control'), state: 'running' }], runtimeLogs: [] },
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.equal(context.modules[0].state, 'running')
assert.equal(context.modules[0].files[0].content, 'code')
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'node.py', content: 'code' }])], runtimeLogs: [] })
}))
})
const files = (name, count, size = 10) => Array.from({ length: count }, (_, index) => ({ path: `${name}-${index}.py`, content: String(index).padEnd(size, 'x') }))
const sourceSize = modules => modules.reduce((sum, module) => sum + module.files.reduce((total, file) => total + file.path.length + file.content.length, 0), 0)
test('balances 24 old and 24 new files instead of failing the second module lookup', async () => {
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
calls++
if (calls === 1) return response(['MKMini'])
if (calls === 2) return response(['Control'])
assert.equal(context.modules.reduce((sum, module) => sum + module.files.length, 0), 24)
for (const name of ['MKMini', 'Control']) {
const module = context.modules.find(module => module.name === name)
assert.equal(module.files.length, 12)
assert.equal(module.fileContext.status, 'limited')
assert.match(module.fileContext.message, /전체 24개 중 12개/)
}
return response()
},
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name, files(name, 24))), runtimeLogs: [] })
}))
assert.equal(calls, 3)
})
test('enforces the combined character budget without slicing source file contents', async () => {
let calls = 0
const contents = new Set(['a'.repeat(60_000), 'b'.repeat(60_000)])
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.ok(sourceSize(context.modules) <= 160_000)
for (const module of context.modules) {
assert.equal(module.files.length, 1)
assert.ok(contents.has(module.files[0].content))
assert.equal(module.fileContext.status, 'limited')
}
return response()
},
loadRequestedContext: async () => ({ modules: ['MKMini', 'Control'].map((name, index) => moduleInfo(name, [
{ path: 'one.py', content: [...contents][index] },
{ path: 'two.py', content: [...contents][index] }
])), runtimeLogs: [] }),
initialContext: { modules: [moduleInfo('MKMini', [{ path: 'one.py', content: [...contents][0] }, { path: 'two.py', content: [...contents][0] }]), moduleInfo('Control')], runtimeLogs: [] }
}))
})
test('prioritizes newly requested source when earlier context nearly fills the character budget', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('MKMini', files('old', 2, 70_000)), moduleInfo('Control')], runtimeLogs: [] },
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').files[0].content, 'new'.repeat(20_000))
assert.ok(context.modules.find(module => module.name === 'MKMini').files.length > 0)
assert.ok(sourceSize(context.modules) <= 160_000)
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'new'.repeat(20_000) }])], runtimeLogs: [] })
}))
})
test('budgets initial source context before sending the first API request', async () => {
const initialContext = { modules: [moduleInfo('Control', files('source', 30))], runtimeLogs: [] }
await resolveAgentContext(options({
initialContext,
requestPlan: async context => {
assert.equal(context.modules[0].files.length, 24)
assert.equal(context.modules[0].fileContext.status, 'limited')
return response()
}
}))
assert.equal(initialContext.modules[0].files.length, 30)
assert.equal(initialContext.modules[0].fileContext, undefined)
})
test('rotates older logs out with a notice rather than failing on a fourth module', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${moduleName} log` })) },
requestPlan: async context => {
if (calls++ === 0) return response([], ['D'])
assert.deepEqual(plain(context.runtimeLogs.map(log => log.moduleName)), ['D', 'C', 'B'])
assert.match(context.runtimeLogs[0].message, /이전 로그 제외: A/)
return response()
},
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [{ moduleName: 'D', state: 'running', content: 'latest error' }] })
}))
})
test('counts log messages in the server budget and keeps recent log content', async () => {
await resolveAgentContext(options({
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${'x'.repeat(20_000)}LATEST`, message: 'm'.repeat(500) })) },
requestPlan: async context => {
assert.equal(context.runtimeLogs.length, 3)
assert.ok(context.runtimeLogs.reduce((sum, log) => sum + log.content.length + (log.message?.length ?? 0), 0) <= 60_000)
for (const log of context.runtimeLogs) {
assert.ok(log.content.endsWith('LATEST'))
assert.match(log.message, /최근 구간/)
assert.ok(log.message.length <= 500)
}
return response()
}
}))
})
test('collects candidates from every requested module and prioritizes configuration before large source trees', async () => {
const source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let collector
function visit(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'collectAgentWorkspace') collector = node.initializer.getText(source)
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(collector)
const api = {}
vm.runInNewContext(ts.transpileModule(`exports.collect = ${collector}`, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, {
exports: api,
availableModules: ['MKMini', 'Control'].map(name => ({ name, state: 'stopped' })),
remoteWorkspace: false,
clonePathPreview: '/workspace',
currentActiveEditorFile: null,
editorFiles: {},
window: { localWorkspace: { moduleFiles: { read: async name => ({ files: Object.fromEntries([
...files(name, 30).map((file, index) => [`src/node${index}.py`, file.content]),
['config/steering.yaml', 'kinematics: ackermann']
]) }) } } }
})
const modules = await api.collect(['MKMini', 'Control'])
assert.deepEqual(plain(modules.map(module => module.files.length)), [24, 24])
for (const module of modules) assert.equal(module.files[0].path, 'config/steering.yaml')
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['MKMini', 'Control'])
for (const module of context.modules) {
assert.equal(module.files.length, 12)
assert.equal(module.files[0].path, 'config/steering.yaml')
}
return response()
},
loadRequestedContext: async request => ({ modules: await api.collect(request.modules), runtimeLogs: [] })
}))
})