368 lines
17 KiB
JavaScript
368 lines
17 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 = {}
|
|
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 })
|
|
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: [] })
|
|
}))
|
|
})
|