feat: add structured agent tools and improve workspace chat UX
This commit is contained in:
+249
-17
@@ -58,13 +58,81 @@ test('keeps logs while loading a later source module', async () => {
|
||||
assert.equal(calls, 3)
|
||||
})
|
||||
|
||||
test('rejects repeated requests instead of returning a stalled plan', async () => {
|
||||
test('makes one analysis-only completion when the model repeats a context request', async () => {
|
||||
let loads = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
requestPlan: async () => response(['Control']),
|
||||
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
|
||||
})), /반복 요청/)
|
||||
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 () => {
|
||||
@@ -90,11 +158,14 @@ test('does not start another API call after cancellation during context loading'
|
||||
|
||||
test('bounds the number of distinct automatic context requests', async () => {
|
||||
let calls = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
requestPlan: async () => response([`Module${calls++}`]),
|
||||
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] })
|
||||
})), /5회/)
|
||||
assert.equal(calls, 6)
|
||||
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 () => {
|
||||
@@ -109,15 +180,30 @@ test('passes file-read failures to the model for a concrete final explanation',
|
||||
}))
|
||||
})
|
||||
|
||||
test('does not silently truncate accumulated source context beyond API limits', async () => {
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
requestPlan: async () => response(['Control']),
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }])], 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('does not display a context-only response when no loader is available', async () => {
|
||||
await assert.rejects(resolveAgentContext(options({ requestPlan: async () => response(['Control']) })), /읽을 수 없는/)
|
||||
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 () => {
|
||||
@@ -133,3 +219,149 @@ test('keeps a just-started module running when a stale collector supplies source
|
||||
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: [] })
|
||||
}))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user