Compare commits

4 Commits

25 changed files with 1175 additions and 80 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.22", "version": "1.2.25",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.2.22", "version": "1.2.25",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.22", "version": "1.2.25",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+2 -1
View File
@@ -188,7 +188,8 @@ test('a new question cancels pending navigation without leaving its old state st
}) })
test('restored interrupted navigation becomes retryable without reopening any file', () => { test('restored interrupted navigation becomes retryable without reopening any file', () => {
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8')) const evidence = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {}, name => name === './agentEvidence' ? evidence : require(name))
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } } const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
const restored = recoverInterruptedAgentMessages([message])[0] const restored = recoverInterruptedAgentMessages([message])[0]
assert.equal(restored.codeReferenceStates.pending.status, 'error') assert.equal(restored.codeReferenceStates.pending.status, 'error')
+5 -1
View File
@@ -5,9 +5,13 @@ const vm = require('node:vm')
const ts = require('typescript') const ts = require('typescript')
const output = {} 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'), { 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 } compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: output, DOMException }) }).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const { resolveAgentContext } = output const { resolveAgentContext } = output
const plain = value => JSON.parse(JSON.stringify(value)) const plain = value => JSON.parse(JSON.stringify(value))
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files }) const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
+105
View File
@@ -0,0 +1,105 @@
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 api = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: api })
const { agentConversationWorkspace: scope, buildAgentConversationContext: build } = api
const now = Date.now()
const local = scope('local', 'local-host', '/workspace/a')
const plain = value => JSON.parse(JSON.stringify(value))
const turn = (n, workspace = local, createdAt = now) => [
{ id: `u${n}`, role: 'user', content: `question-${n}`, conversationScope: { workspace, createdAt } },
{ id: `a${n}`, role: 'assistant', content: `summary-${n}`, conversationScope: { workspace, createdAt }, plan: {
summary: `summary-${n}`, explanation: `answer-${n}`, conversationSummary: `누적 기억-${n}`, changes: [], commands: [], requestedModules: [], requestedLogs: [], createModule: null
} }
]
test('uses the latest cumulative summary with only two recent exchanges and no duplicate current question', () => {
const messages = Array.from({ length: 20 }, (_, i) => turn(i)).flat()
messages.push({ ...turn(20)[0], content: 'pending current question' })
const context = build(messages, local, now)
assert.deepEqual(plain(context.recentMessages).map(item => item.content), ['question-18', 'summary-18\nanswer-18', 'question-19', 'summary-19\nanswer-19'])
assert.equal(context.summary, '누적 기억-19')
assert.doesNotMatch(JSON.stringify(context), /question-17|pending current question/)
})
test('hard caps payload text even with huge original answers and requests', () => {
const messages = Array.from({ length: 10 }, (_, i) => {
const pair = turn(i)
pair[0].content = 'q'.repeat(4000)
pair[1].plan.summary = 's'.repeat(500)
pair[1].plan.explanation = 'e'.repeat(12000)
pair[1].plan.conversationSummary = 'm'.repeat(2000)
return pair
}).flat()
const context = build(messages, local, now)
assert.ok(context.summary.length <= 1000)
assert.equal(context.recentMessages.length, 4)
assert.ok(context.recentMessages.every(message => message.content.length <= 700))
assert.ok(context.summary.length + context.recentMessages.reduce((sum, item) => sum + item.content.length, 0) <= 3800)
})
test('does not mix hosts, roots, SSH identities, unscoped legacy messages or expired history', () => {
for (const other of [scope('remote', 'ssh:a', '/workspace/a'), scope('local', 'local-host', '/workspace/b'), scope('remote', 'ssh:b', '/workspace/a')]) {
assert.equal(build(turn(1, other), local, now), undefined)
}
assert.equal(build(turn(1, local, now - 86400000), local, now), undefined)
assert.equal(build(turn(1, local, now + 1), local, now), undefined)
assert.equal(build(turn(1).map(({ conversationScope, ...message }) => message), local, now), undefined)
assert.equal(scope('local', 'local-host'), undefined)
assert.equal(build(turn(1), undefined, now), undefined)
assert.equal(build([], local, now), undefined)
})
test('a missing or empty generated summary preserves earlier valid memory without an extra model call', () => {
const first = turn(1), second = turn(2)
delete second[1].plan.conversationSummary
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
second[1].plan.conversationSummary = ''
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
delete first[1].plan.conversationSummary
assert.equal(build([...first, ...second], local, now).summary, '')
second[1].plan.conversationSummary = '새 누적 요약'
assert.equal(build([...first, ...second], local, now).summary, '새 누적 요약')
})
test('keeps file identities and action outcomes, but excludes payloads and tool evidence', () => {
const pair = turn(1)
pair[1].plan.explanation = '설명\n```python\nPRIVATE_SOURCE\n```\n끝'
pair[1].plan.changes = [{ moduleName: 'Control', path: 'config.yaml', content: 'PRIVATE_CHANGE' }]
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build', command: 'PRIVATE_COMMAND' }]
pair[1].plan.evidence = [{ data: 'PRIVATE_LOG' }]
pair[1].plan.codeReferences = [{ moduleName: 'Control', path: 'node.py', startLine: 20, endLine: 23, snippet: 'PRIVATE_SNIPPET' }]
pair[1].commandExecutions = { build: { status: 'completed', output: 'PRIVATE_OUTPUT' } }
pair[1].applyState = 'cancelled'
const context = JSON.stringify(build(pair, local, now))
assert.match(context, /Control\/node.py:20-23/)
assert.match(context, /file change: cancelled/)
assert.match(context, /build: completed/)
assert.doesNotMatch(context, /PRIVATE_/)
})
test('ignores errors, interrupted turns and initial lookup responses', () => {
const pair = turn(1)
const error = { ...pair[1], plan: undefined, content: 'network error' }
assert.equal(build([pair[0], error], local, now), undefined)
pair[1].plan.toolRequests = [{ name: 'files.read' }]
assert.equal(build(pair, local, now), undefined)
assert.equal(build([turn(1)[0], ...turn(2, scope('remote', 'ssh:x', '/workspace'))], local, now), undefined)
})
test('rebuilds from persisted metadata and reflects updated execution outcomes without new memory storage', () => {
const pair = turn(1)
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build' }]
assert.match(JSON.stringify(build(plain(pair), local, now)), /pending/)
pair[1].commandExecutions = { build: { status: 'failed' } }
assert.match(JSON.stringify(build(plain(pair), local, now)), /failed/)
const followUp = { ...pair[1], content: 'result', plan: { ...pair[1].plan, summary: '실행 결과 분석', explanation: '실패했습니다.' } }
assert.match(JSON.stringify(build([...pair, followUp], local, now)), /실행 결과 분석/)
assert.equal(build([], local, now), undefined)
})
+143
View File
@@ -0,0 +1,143 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ts = require('typescript')
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, filename)
const { compactAgentEvidence, missingAgentRead, readFileEvidence } = require('../src/renderer/src/features/workspace/agentEvidence.ts')
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
const file = (startLine, endLine, extra = {}, result = {}) => ({
id: `read-${startLine}-${endLine}`, name: 'files.read', moduleName: 'Control', target: 'ssh:jetson', status: 'ok', observedAt: '2026-10-20T00:00:00.000Z', truncated: true,
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), content: Array.from({ length: endLine - startLine + 1 }, (_, i) => `line${startLine + i}`).join('\n'),
startLine, endLine, totalLines: 100, complete: false, contentTruncated: false, ...extra }), ...result
})
const read = (startLine, lineCount, path = 'config/car.yaml') => ({ id: 'read', name: 'files.read', moduleName: 'Control', args: { path, ...(startLine === undefined ? {} : { startLine }), ...(lineCount === undefined ? {} : { lineCount }) } })
const answer = toolRequests => ({ provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'verified code', toolRequests, changes: [], commands: [], actionPlan: [], createModule: null } })
test('overlapping and adjacent ranges merge without promoting partial evidence to editable full files', () => {
const results = compactAgentEvidence([file(1, 10), file(8, 15), file(16, 20)])
assert.equal(results.length, 1)
const data = readFileEvidence(results[0])
assert.equal(data.startLine, 1)
assert.equal(data.endLine, 20)
assert.equal(data.content.split('\n').length, 20)
assert.equal(data.complete, false)
assert.equal(results[0].truncated, true)
assert.equal(missingAgentRead(read(3, 16), results), undefined)
assert.deepEqual(missingAgentRead(read(), results), read())
})
test('queries only the first missing range and never joins disjoint or conflicting content', () => {
const results = compactAgentEvidence([file(1, 5), file(10, 15)])
assert.equal(results.length, 2)
assert.deepEqual(missingAgentRead(read(3, 15), results).args, { path: 'config/car.yaml', startLine: 6, lineCount: 4 })
assert.equal(compactAgentEvidence([file(1, 10), file(8, 15, { content: 'different\ncontent' })]).length, 2)
})
test('host boundaries and file revisions cannot be combined or used to fill each other’s gaps', () => {
const changed = compactAgentEvidence([file(1, 10), file(11, 20, { hash: 'b'.repeat(64) })])
assert.equal(changed.length, 1)
assert.equal(missingAgentRead(read(1, 20), changed).args.startLine, 1)
const hosts = compactAgentEvidence([file(1, 10, {}, { target: 'local-host' }), file(11, 20)])
assert.equal(hosts.length, 2)
assert.equal(missingAgentRead(read(1, 20), hosts).args.startLine, 1)
})
test('full and missing observations are reused but partial character fragments are never coverage', () => {
const full = file(1, 10, { complete: true }, { truncated: false })
assert.equal(missingAgentRead(read(5, 100), [full]), undefined)
assert.equal(compactAgentEvidence([full, file(5, 8)]).length, 1)
const missing = file(1, 1, { exists: false, content: '', hash: null, complete: true }, { truncated: false })
assert.equal(missingAgentRead(read(), [missing]), undefined)
const cut = file(1, 2, { content: 'whole\ncut', endLine: 1, contentTruncated: true })
assert.equal(missingAgentRead(read(2, 1), [cut]).args.startLine, 2)
const legacy = file(1, 2, { endLine: undefined, contentTruncated: undefined })
assert.equal(missingAgentRead(read(2, 1), [legacy]).args.startLine, 2)
})
test('malformed or oversized evidence never becomes reusable complete coverage', () => {
assert.equal(readFileEvidence(file(1, 10, { hash: 'not-a-hash' })), undefined)
assert.equal(readFileEvidence(file(1, 10, { endLine: 999 })), undefined)
const large = compactAgentEvidence([file(1, 1, { content: 'a'.repeat(30_000) }), file(2, 2, { content: 'b'.repeat(30_000) })])
assert.equal(large.length, 2)
})
test('overlap requests execute only missing lines and finish once the requested range is cached', async () => {
const executions = []
let calls = 0
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
executeTool: async tool => { executions.push(tool.args); return file(tool.args.startLine, tool.args.startLine + tool.args.lineCount - 1) },
requestPlan: async (context, final) => {
calls++
if (final) {
assert.equal(context.toolResults.length, 1)
assert.equal(JSON.parse(context.toolResults[0].data).endLine, 15)
return answer([])
}
return answer([[read(1, 10)], [read(5, 11)], [read(4, 10)]][calls - 1])
}
})
assert.deepEqual(executions, [read(1, 10).args, read(11, 5).args])
assert.equal(calls, 4)
})
test('source evidence survives many later searches and omissions are reported within DTO limits', async () => {
const searchResults = Array.from({ length: 15 }, (_, i) => ({ ...file(1, 1), id: `search-${i}`, name: 'files.search', data: JSON.stringify({ query: `speed${i}`, matches: [] }) }))
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10), ...searchResults] }, signal: new AbortController().signal,
requestPlan: async context => {
assert.equal(context.toolResults.length, 12)
assert.equal(context.toolResults[0].name, 'files.read')
assert.match(context.contextNotes[0], /4개/)
assert.ok(context.contextNotes.length <= 12)
assert.ok(context.contextNotes.every(note => note.length <= 500))
return answer([])
}
})
})
test('a cached file omitted by budgets is restored when the model requests it again', async () => {
let calls = 0
const results = Array.from({ length: 13 }, (_, i) => file(1, 10, { path: `file${i}.yaml` }))
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: results }, signal: new AbortController().signal,
executeTool: async () => { throw Error('cached file must not be queried again') },
requestPlan: async (context, final) => {
calls++
if (!final) return answer([read(1, 5, 'file0.yaml')])
assert.ok(context.toolResults.some(result => JSON.parse(result.data).path === 'file0.yaml'))
return answer([])
}
})
assert.equal(calls, 2)
})
test('finalization retains verified code locations while stripping all proposed mutations', async () => {
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
executeTool: async () => { throw Error('already cached') },
requestPlan: async (_, final) => ({ ...answer([read(1, 5)]), plan: { ...answer([read(1, 5)]).plan, ...(final ? { changes: [{ path: 'unsafe' }], commands: [{ command: 'unsafe' }] } : { codeReferences: [reference] }) } })
})
assert.equal(result.plan.codeReferences.length, 1)
assert.match(result.plan.explanation, /verified code/)
assert.deepEqual(result.plan.changes, [])
assert.deepEqual(result.plan.commands, [])
})
test('recovery never resurrects code locations after a different file revision was observed', async () => {
let calls = 0
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
executeTool: async () => file(11, 20, { hash: 'b'.repeat(64) }),
requestPlan: async (_, final) => {
calls++
return !final && calls === 1 ? { ...answer([read(11, 10)]), plan: { ...answer([read(11, 10)]).plan, codeReferences: [reference] } } : answer([read(11, 10)])
}
})
assert.deepEqual(result.plan.codeReferences, [])
assert.match(result.plan.summary, /확정하지 못했습니다/)
})
+99
View File
@@ -0,0 +1,99 @@
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 React = require('react')
function compile(path, overrides = {}) {
const exports = {}
const source = fs.readFileSync(require.resolve(path), 'utf8')
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText,
{ exports, document: { body: {} }, require: name => overrides[name] ?? require(name) })
return exports
}
const models = compile('../src/renderer/src/features/workspace/agentModels.ts')
const options = [{ provider: 'OPENAI', model: 'gpt-6.1-sol', imagesEnabled: false }, { provider: 'QWEN', model: 'qwen3.8-flash', imagesEnabled: true }]
const plain = value => JSON.parse(JSON.stringify(value))
test('normalizes enabled models, validates IDs and preserves old single-model servers', () => {
const status = models.normalizeAgentStatus({ enabled: true, models: [options[1], options[0], options[0], { provider: 'FORGED', model: 'bad' }, { provider: 'GEMINI', model: '' }] })
assert.deepEqual(plain(status.models), options)
assert.equal(status.selectionSupported, true)
assert.deepEqual(plain(models.normalizeAgentStatus({ enabled: true, ...options[1] }).models), [options[1]])
assert.equal(models.normalizeAgentStatus({ enabled: true, ...options[1] }).selectionSupported, false)
assert.equal(models.normalizeAgentStatus({ enabled: false, models: options }).models.length, 0)
assert.equal(models.normalizeAgentStatus({ enabled: true, models: [] }).enabled, false)
})
test('selects the saved provider only while it is available, and never invents one', () => {
assert.equal(models.selectedAgentModel(options, 'QWEN'), options[1])
assert.equal(models.selectedAgentModel(options, 'ANTHROPIC'), options[0])
assert.equal(models.selectedAgentModel([options[0]], 'QWEN'), options[0])
assert.equal(models.selectedAgentModel([], 'QWEN'), null)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function selectorHarness(props) {
const state = [], refs = []
let stateIndex = 0, refIndex = 0
const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', {
'../i18n': { t: value => value }, '../features/workspace/agentModels': models,
'react-dom': { createPortal: child => child },
react: { ...React, useId: () => 'model-menu', useEffect() {}, useLayoutEffect() {},
useState(initial) { const index = stateIndex++; if (!(index in state)) state[index] = initial; return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] },
useRef(initial) { return refs[refIndex++] ?? (refs[refIndex - 1] = { current: initial }) }
}
})
return { render(patch = {}) { stateIndex = 0; refIndex = 0; return AgentModelSelector({ ...props, ...patch }) } }
}
for (const dark of [true, false]) test(`model control lists only allowed providers and follows the theme (${dark})`, () => {
let selected
const h = selectorHarness({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } })
const tree = h.render()
const trigger = elements(tree, node => node.type === 'button')[0]
assert.equal(trigger.props['aria-haspopup'], 'menu')
assert.equal(trigger.props['aria-expanded'], false)
assert.equal(trigger.props.title, 'Qwen · qwen3.8-flash')
assert.equal(elements(tree, node => node.props.role === 'menu').length, 0)
assert.equal(elements(tree, node => node.type === 'select').length, 0)
trigger.props.onClick()
const menu = elements(h.render(), node => node.props.role === 'menu')[0]
assert.match(menu.props.className, dark ? /bg-\[#252526\]/ : /bg-white/)
assert.match(menu.props.className, /fixed/)
const items = elements(menu, node => node.props.role === 'menuitemradio')
assert.deepEqual(items.map(node => node.props.title), ['OpenAI · gpt-6.1-sol', 'Qwen · qwen3.8-flash'])
assert.deepEqual(items.map(node => node.props['aria-checked']), [false, true])
assert.ok(items.every(node => elements(node, item => item.type === 'span' && item.props.className.includes('truncate')).length === 1))
items[0].props.onClick()
assert.equal(selected, 'OPENAI')
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 0)
assert.ok(elements(h.render({ disabled: true }), node => node.type === 'button')[0].props.disabled)
const empty = elements(h.render({ models: [], provider: null }), node => node.type === 'button')[0]
assert.ok(empty.props.disabled)
assert.equal(empty.props.title, '사용 가능한 모델 없음')
})
test('model menu opens from arrow keys and closes when a request blocks the selector', () => {
const h = selectorHarness({ models: options, provider: 'QWEN', dark: true, disabled: false, onChange() {} })
const trigger = elements(h.render(), node => node.type === 'button')[0]
trigger.props.onKeyDown({ key: 'ArrowUp', preventDefault() {} })
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 1)
assert.equal(elements(h.render({ disabled: true }), node => node.props.role === 'menu').length, 0)
})
test('input footer places the model action before the attachment and clear controls', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
const row = source.slice(source.indexOf('{(attachmentsEnabled || onClearConversation || footerAction)'))
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'파일 첨부\')}'))
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'대화내역 삭제\')}'))
})
test('A-Copilot has a single managed label', () => {
assert.equal(models.agentModelLabel({ provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }), 'A-Copilot')
assert.equal(models.agentModelLabel(options[0]), 'OpenAI · gpt-6.1-sol')
})
+175 -7
View File
@@ -11,7 +11,11 @@ function compile(source, globals = {}) {
}).outputText, { exports, DOMException, Error, ...globals }) }).outputText, { exports, DOMException, Error, ...globals })
return exports return exports
} }
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8')) const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
require: name => { assert.equal(name, './agentEvidence'); return evidenceApi }
})
const conversation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'))
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true) const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent') const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source))) const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
@@ -26,22 +30,110 @@ function harness(requestApi) {
let now = 1000 let now = 1000
class Clock extends Date { static now() { return now } } class Clock extends Date { static now() { return now } }
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null } const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
const messageStateRef = { current: { session } } const messageStateRef = { current: { session, messages: [] } }
const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true }
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), { const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
...context, session, messageStateRef, AbortController, ...context, ...conversation, session, messageStateRef, AbortController, FormData,
Date: Clock, Date: Clock,
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
status: { enabled: true }, abortControllerRef: { current: null }, selectedModel,
status: { enabled: true, selectionSupported: true }, abortControllerRef: { current: null },
setPending: value => { state.pending = value }, setPending: value => { state.pending = value },
setRequestStartedAt: value => { state.requestStartedAt = value }, setRequestStartedAt: value => { state.requestStartedAt = value },
setActivity: value => { state.activity = value }, setActivity: value => { state.activity = value },
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value }, setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
updateMessages: update => { state.messages = update(state.messages); state.saved.push(plain(state.messages)) }, updateMessages: update => { state.messages = update(state.messages); messageStateRef.current.messages = state.messages; state.saved.push(plain(state.messages)) },
onResponseRef: { current: () => { state.notices++ } }, requestApi onResponseRef: { current: () => { state.notices++ } }, requestApi
}) })
return { ...api, state, messageStateRef, advance: ms => { now += ms } } return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } }
} }
test('shows and persists the question, pending state and timer before a slow module snapshot finishes', async () => {
const snapshot = deferred(), completion = deferred()
const bodies = []
let loads = 0
const h = harness((_, request) => { bodies.push(JSON.parse(request.body)); return completion.promise })
const running = h.requestPlan('원격 차량 속도 제한 위치', [], [], {
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10', executeTool: async () => result(),
loadInitialContext: signal => { loads++; assert.equal(signal.aborted, false); return snapshot.promise }
})
assert.equal(h.state.messages[0].content, '원격 차량 속도 제한 위치')
assert.equal(h.state.saved[0][0].role, 'user')
assert.equal(h.state.pending, true)
assert.equal(h.state.requestStartedAt, 1000)
assert.equal(h.state.activity, '모듈 상태 조회 중')
assert.equal(bodies.length, 0)
await h.requestPlan('중복 전송', [], [], { loadInitialContext: () => { loads++; return snapshot.promise } })
assert.equal(loads, 1)
assert.equal(h.state.messages.length, 1)
h.advance(5000)
h.selectedModel.provider = 'OPENAI'
snapshot.resolve({ modules: [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], runtimeLogs: [] })
await tick()
assert.equal(bodies.length, 1)
assert.equal(bodies[0].provider, 'QWEN')
assert.equal(bodies[0].modules[0].state, 'running')
assert.deepEqual(bodies[0].modules[0].parameters, ['sim_mode:=false'])
h.advance(2000)
completion.resolve(response())
await running
assert.equal(h.state.messages[1].durationMs, 7000)
assert.equal(h.state.pending, false)
})
for (const reject of [false, true]) test(`cancels during initial module lookup and ignores its late ${reject ? 'failure' : 'result'} without affecting a new request`, async () => {
const old = deferred(), next = deferred()
let calls = 0, signal
const h = harness(async () => { calls++; return response() })
const first = h.requestPlan('첫 질문', [], [], { loadInitialContext: current => { signal = current; return old.promise } })
h.stop()
assert.equal(signal.aborted, true)
assert.equal(h.state.pending, false)
const second = h.requestPlan('다음 질문', [], [], { loadInitialContext: () => next.promise })
if (reject) old.reject(Error('늦게 도착한 SSH 오류'))
else old.resolve({ modules: [], runtimeLogs: [] })
await first
assert.equal(calls, 0)
assert.equal(h.state.pending, true)
assert.equal(h.state.activity, '모듈 상태 조회 중')
assert.equal(h.state.messages.length, 2)
assert.equal(h.state.notices, 0)
next.resolve({ modules: [], runtimeLogs: [] })
await second
assert.equal(calls, 1)
assert.equal(h.state.messages.length, 3)
})
test('initial snapshot errors preserve the question and show a timed error without calling the model', async () => {
const snapshot = deferred()
let calls = 0
const h = harness(async () => { calls++; return response() })
const running = h.requestPlan('질문', [], [], { loadInitialContext: () => snapshot.promise })
h.advance(3000)
snapshot.reject(Error('원격 모듈 상태 조회 실패'))
await running
assert.equal(calls, 0)
assert.equal(h.state.messages[0].content, '질문')
assert.equal(h.state.messages[1].content, '원격 모듈 상태 조회 실패')
assert.equal(h.state.messages[1].durationMs, 3000)
assert.equal(h.state.pending, false)
assert.equal(h.state.requestStartedAt, null)
assert.equal(h.state.activity, '')
})
test('scope changes during initial snapshot loading suppress later API calls and errors', async () => {
const snapshot = deferred()
let calls = 0
const h = harness(async () => { calls++; return response() })
const running = h.requestPlan('이전 강좌 질문', [], [], { loadInitialContext: () => snapshot.promise })
h.messageStateRef.current.session = {}
snapshot.resolve({ modules: [], runtimeLogs: [] })
await running
assert.equal(calls, 0)
assert.equal(h.state.messages.length, 1)
assert.equal(h.state.notices, 0)
})
test('publishes completed stages immediately without persisting progress or adding model calls', async () => { test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
const first = deferred(), tool = deferred(), final = deferred() const first = deferred(), tool = deferred(), final = deferred()
let calls = 0 let calls = 0
@@ -76,6 +168,82 @@ test('publishes completed stages immediately without persisting progress or addi
assert.equal(h.state.notices, 1) assert.equal(h.state.notices, 1)
}) })
test('image uploads and every lookup round retain the original provider without uploading the image twice', async () => {
const bodies = []
let uploads = 0
const h = harness(async (path, request) => {
if (path.endsWith('/attachments/images')) {
uploads++
assert.equal(request.body.get('provider'), 'QWEN')
assert.equal(request.body.get('courseId'), 'course')
assert.equal(request.body.get('week'), '3')
assert.equal(request.body.get('file').name, 'screen.png')
return { token: 'image-reference', name: 'screen.png', url: 'https://images.test/screen.png' }
}
bodies.push(JSON.parse(request.body))
h.selectedModel.provider = 'OPENAI'
return bodies.length === 1
? { ...response(), plan: { ...response().plan, requestedModules: ['Control'] } }
: response()
})
await h.requestPlan('이미지 확인', [{ name: 'Control', files: [] }], [], {
attachments: [{ kind: 'image', name: 'screen.png', file: new Blob(['fixture'], { type: 'image/png' }) }],
loadRequestedContext: async () => ({ modules: [{ name: 'Control', files: [{ path: 'config/control.yaml', content: 'speed: 0.3' }] }], runtimeLogs: [] })
})
assert.equal(uploads, 1)
assert.equal(bodies.length, 2)
assert.ok(bodies.every(body => body.provider === 'QWEN'))
assert.ok(bodies.every(body => body.imageTokens[0] === 'image-reference'))
assert.equal(h.state.messages.at(-1).model, 'fixture')
})
test('remote target and observed source ranges reach each provider request including finalization', async () => {
const bodies = []
const tool = { id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config/car.yaml', startLine: 20, lineCount: 1 } }
const h = harness(async (_, request) => {
bodies.push(JSON.parse(request.body))
return bodies.length < 3 ? response([tool]) : response()
})
await h.requestPlan('속도 제한 코드 위치', [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], [], {
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10',
executeTool: async input => result({ id: input.id, moduleName: 'Control', target: 'ssh:100.64.0.10', truncated: true,
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), complete: false, content: 'max_speed: 0.4', startLine: 20, endLine: 20, contentTruncated: false, totalLines: 100 }) })
})
assert.equal(bodies.length, 3)
for (const body of bodies) {
assert.equal(body.environment, 'remote')
assert.equal(body.workspaceTarget, 'ssh:100.64.0.10')
assert.deepEqual(body.modules[0].parameters, ['sim_mode:=false'])
}
assert.equal(JSON.parse(bodies[1].toolResults[0].data).content, 'max_speed: 0.4')
assert.equal(JSON.parse(bodies[2].toolResults[0].data).endLine, 20)
assert.match(bodies[2].request, /자료 조회는 종료되었습니다/)
})
test('sends only prior same-workspace context and keeps it stable throughout tool lookups', async () => {
const bodies = []
const h = harness(async (_, request) => {
bodies.push(JSON.parse(request.body))
const reply = bodies.length === 2 ? response([{ id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config.yaml' } }]) : response()
return { ...reply, plan: { ...reply.plan, conversationSummary: 'Control의 config.yaml 설명을 진행 중입니다.' } }
})
const options = { environment: 'local', workspaceTarget: 'local-host', workspaceRoot: '/workspace/a', executeTool: async () => result() }
await h.requestPlan('Control 설정 위치', [], [], options)
assert.equal(bodies[0].conversationContext, undefined)
await h.requestPlan('그 파일 설명해줘', [], [], options)
assert.equal(bodies.length, 3)
assert.equal(bodies[1].conversationContext.recentMessages[0].content, 'Control 설정 위치')
assert.equal(bodies[1].conversationContext.summary, 'Control의 config.yaml 설명을 진행 중입니다.')
assert.equal(h.state.saved[1][1].plan.conversationSummary, 'Control의 config.yaml 설명을 진행 중입니다.')
assert.equal(JSON.stringify(bodies[1].conversationContext).includes('그 파일 설명해줘'), false)
assert.deepEqual(bodies[1].conversationContext, bodies[2].conversationContext)
assert.equal(bodies[1].workspaceRoot, undefined)
await h.requestPlan('다른 원격 질문', [], [], { ...options, environment: 'remote', workspaceTarget: 'ssh:device' })
assert.equal(bodies.at(-1).conversationContext, undefined)
await h.requestPlan('다른 로컬 폴더', [], [], { ...options, workspaceRoot: '/workspace/b' })
assert.equal(bodies.at(-1).conversationContext, undefined)
})
test('errors replace temporary progress with the existing error reply', async () => { test('errors replace temporary progress with the existing error reply', async () => {
let calls = 0 let calls = 0
const h = harness(async () => { const h = harness(async () => {
+21 -5
View File
@@ -4,9 +4,13 @@ const fs = require('node:fs')
const vm = require('node:vm') const vm = require('node:vm')
const ts = require('typescript') const ts = require('typescript')
const contextApi = {} const contextApi = {}
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'), { 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 } compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: contextApi, DOMException }) }).outputText, { exports: contextApi, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true) const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression let payloadExpression
@@ -28,15 +32,16 @@ test('user messages preserve only typed text while attachments remain separate m
find(source) find(source)
assert.ok(initializer) assert.ok(initializer)
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) { for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
const message = vm.runInNewContext(`(${initializer})`, { request, options: { attachments: [{ name: '스크린샷.png' }] } }) const message = vm.runInNewContext(`(${initializer})`, { request, conversationScope: undefined, options: { attachments: [{ name: '스크린샷.png' }] } })
assert.equal(message.content, request) assert.equal(message.content, request)
} }
}) })
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = []) { function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
return JSON.parse(vm.runInNewContext(payloadExpression, { return JSON.parse(vm.runInNewContext(payloadExpression, {
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize, courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
textAttachments: attachments ?? [], imageTokens, textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
selection: provider ? { provider } : {},
agentPlanRequestText: contextApi.agentPlanRequestText, agentPlanRequestText: contextApi.agentPlanRequestText,
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] } context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
})) }))
@@ -70,7 +75,7 @@ test('finalization instruction reaches the API without sending unsupported DTO f
assert.match(body.request, /설정 위치 알려줘/) assert.match(body.request, /설정 위치 알려줘/)
assert.match(body.request, /최종 답변/) assert.match(body.request, /최종 답변/)
assert.equal(Object.hasOwn(body, 'finalize'), false) assert.equal(Object.hasOwn(body, 'finalize'), false)
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'request', 'runtimeLogs', 'week']) assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'provider', 'request', 'runtimeLogs', 'week'])
}) })
test('finalization keeps the request within the backend 4000-character limit', () => { test('finalization keeps the request within the backend 4000-character limit', () => {
@@ -78,3 +83,14 @@ test('finalization keeps the request within the backend 4000-character limit', (
assert.equal(body.request.length, 4000) assert.equal(body.request.length, 4000)
assert.match(body.request, /원래 학생 질문/) assert.match(body.request, /원래 학생 질문/)
}) })
test('sends only a provider selection rather than student-supplied models or keys', () => {
for (const finalize of [false, true]) {
const body = serialize(undefined, finalize, '확인', [], 'OPENAI')
assert.equal(body.provider, 'OPENAI')
assert.equal(Object.hasOwn(body, 'model'), false)
assert.equal(Object.hasOwn(body, 'apiKey'), false)
assert.equal(Object.hasOwn(body, 'endpointUrl'), false)
}
assert.equal(Object.hasOwn(serialize(undefined, false, '확인', [], null), 'provider'), false)
})
+6
View File
@@ -92,6 +92,12 @@ function renderResponse(message, now, reducedMotion = false, extra = {}) {
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra })) return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
} }
test('internal cumulative memory is not rendered in the response bubble', () => {
const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0)
assert.match(html, /화면 설명/)
assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/)
})
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => { test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0) const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
assert.ok(html.includes('A-Copilot')) assert.ok(html.includes('A-Copilot'))
+15
View File
@@ -63,15 +63,30 @@ for (const backend of ['local', 'remote']) {
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' })) 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.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
assert.equal(full.data.complete, true) 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 })) 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.content, '조향: ackermann')
assert.equal(partial.data.complete, false) assert.equal(partial.data.complete, false)
assert.equal(partial.truncated, true) 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' })) const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
assert.equal(missing.data.exists, false) assert.equal(missing.data.exists, false)
assert.equal(missing.data.hash, null) assert.equal(missing.data.hash, null)
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' }))) 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 => { test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
const root = await fixture(t) const root = await fixture(t)
await fs.mkdir(path.join(root, 'code')) await fs.mkdir(path.join(root, 'code'))
+77
View File
@@ -0,0 +1,77 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ts = require('typescript')
const vm = require('node:vm')
const app = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declarations = new Map()
function visit(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) declarations.set(node.name.text, node.initializer?.getText(app))
ts.forEachChild(node, visit)
}
visit(app)
const plain = value => JSON.parse(JSON.stringify(value))
function harness(remoteWorkspace) {
const executions = []
const calls = []
const contexts = []
const events = []
const errors = []
const target = { host: '100.64.0.10', user: 'fixture', workspaceRoot: '/fixture/workspace' }
const live = [{ name: 'NAV2-MKMini', folderName: 'vehicle', state: 'running', parameters: ['sim_mode:=false', 'robot:=mkmini'] }]
const readTool = backend => async (...args) => { executions.push({ backend, args: plain(args) }); return { status: 'ok', data: '{"content":"remote source"}' } }
const exports = {}
const globals = {
exports, remoteWorkspace, availableModules: [{ ...live[0], state: 'stopped', parameters: ['sim_mode:=true'] }],
clonePathPreview: '/local/workspace', runtimeModules: [], agentActionControllerRef: { current: null }, agentApplying: false,
getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules,
validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [],
window: {
remoteWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('remote') } },
localWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('local') } }
},
requestAgentPlan: async (...args) => {
calls.push(args)
events.push('plan-start')
contexts.push(await args[3].loadInitialContext(new AbortController().signal))
},
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
}
const names = ['collectAgentWorkspace', 'readAgentModules', 'executeWorkspaceAgentTool', 'submitAgentRequest']
vm.runInNewContext(ts.transpileModule(names.map(name => `const ${name} = ${declarations.get(name)}; exports.${name} = ${name}`).join('\n'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, globals)
return { ...exports, executions, calls, contexts, events, errors }
}
for (const remote of [false, true]) {
test(`${remote ? 'remote' : 'local'}: enters the chat lifecycle immediately and refreshes state before the provider request`, async () => {
const h = harness(remote)
await h.submitAgentRequest('최대 속도 제한 코드 위치')
assert.deepEqual(h.errors, [])
assert.equal(h.calls.length, 1)
const [, initialModules, , options] = h.calls[0]
assert.deepEqual(plain(initialModules), [])
assert.deepEqual(h.events, ['plan-start', 'snapshot-read'])
const { modules } = h.contexts[0]
assert.equal(modules[0].state, 'running')
assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini'])
assert.deepEqual(plain(modules[0].files), [])
assert.equal(options.environment, remote ? 'remote' : 'local')
assert.equal(options.workspaceTarget, remote ? 'ssh:100.64.0.10' : 'local-host')
})
test(`${remote ? 'remote' : 'local'}: source reads use only the connected workspace transport and module folder`, async () => {
const h = harness(remote)
const tool = { id: 'read-1', name: 'files.read', moduleName: 'NAV2-MKMini', args: { path: 'src/profiles.py', startLine: 10, lineCount: 20 } }
await h.executeWorkspaceAgentTool(tool)
assert.equal(h.executions.length, 1)
assert.equal(h.executions[0].backend, remote ? 'remote' : 'local')
const args = h.executions[0].args
assert.deepEqual(args[remote ? 1 : 0], tool)
assert.equal(args[2], 'vehicle')
assert.equal(remote ? args[0].host : args[1], remote ? '100.64.0.10' : '/local/workspace')
})
}
+1
View File
@@ -42,6 +42,7 @@ function setup(overrides = {}, browser = {}) {
'../../components/AgentProgressBubble': {}, '../../components/AgentProgressBubble': {},
'../../components/AgentWorkDuration': {}, '../../components/AgentWorkDuration': {},
'../../components/AgentCodeReferences': {}, '../../components/AgentCodeReferences': {},
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
'react-dom': { createPortal: child => child }, 'react-dom': { createPortal: child => child },
react: { react: {
...React, ...React,
+7 -3
View File
@@ -43,10 +43,14 @@ export async function runAgentFileTool(root: string, request: AgentToolRequest):
try { try {
const content = await readText(root, path) const content = await readText(root, path)
const lines = content.split('\n') const lines = content.split('\n')
const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
const startLine = args.startLine ?? 1 const startLine = args.startLine ?? 1
const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial } const selection = ranged ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n') : content
const selected = selection.slice(0, 40_000)
const contentTruncated = selected.length < selection.length
const complete = startLine === 1 && selected === content
const endLine = Math.min(lines.length, startLine + selected.split('\n').length - 1 - Number(contentTruncated))
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete, startLine, endLine, contentTruncated, totalLines: lines.length }, truncated: !complete }
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
// A missing leaf in an existing directory is evidence for a new file. // A missing leaf in an existing directory is evidence for a new file.
+8 -3
View File
@@ -116,10 +116,15 @@ else:
try: try:
content = read(root, path) content = read(root, path)
lines = content.split('\n') lines = content.split('\n')
partial = 'startLine' in options or 'lineCount' in options or len(content) > 40000 ranged = 'startLine' in options or 'lineCount' in options or len(content) > 40000
start = options.get('startLine', 1) start = options.get('startLine', 1)
selected = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)])[:40000] if partial else content selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': not partial, 'startLine': start, 'totalLines': len(lines)} selected = selection[:40000]
content_truncated = len(selected) < len(selection)
complete = start == 1 and selected == content
partial = not complete
end = min(len(lines), start + len(selected.split('\n')) - 1 - int(content_truncated))
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': complete, 'startLine': start, 'endLine': end, 'contentTruncated': content_truncated, 'totalLines': len(lines)}
except FileNotFoundError: except FileNotFoundError:
checked(root, path, True) checked(root, path, True)
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
+22 -8
View File
@@ -630,6 +630,8 @@ function LocalPcWorkspaceMockup({
enabled: agentEnabled, enabled: agentEnabled,
provider: agentProvider, provider: agentProvider,
imagesEnabled: agentImagesEnabled, imagesEnabled: agentImagesEnabled,
models: agentModels,
selectProvider: selectAgentProvider,
statusLoading: agentStatusLoading, statusLoading: agentStatusLoading,
pending: agentPending, pending: agentPending,
activity: agentActivity, activity: agentActivity,
@@ -3526,9 +3528,9 @@ function LocalPcWorkspaceMockup({
const createCustomModule = async (input: CustomModuleInput): Promise<void> => { const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
await createCustomModuleRecord(input) await createCustomModuleRecord(input)
} }
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => { const collectAgentWorkspace = async (requestedModuleNames: string[] = [], moduleSnapshot = availableModules): Promise<AgentWorkspaceModule[]> => {
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase())) const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({ const workspaceModules: AgentWorkspaceModule[] = moduleSnapshot.map((module) => ({
name: module.name, name: module.name,
folderName: module.folderName, folderName: module.folderName,
category: module.category, category: module.category,
@@ -3542,7 +3544,7 @@ function LocalPcWorkspaceMockup({
} }
} : {}) } : {})
})) }))
const targetModules = availableModules.filter((module) => ( const targetModules = moduleSnapshot.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase()) module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
)) ))
if (!targetModules.length) return workspaceModules if (!targetModules.length) return workspaceModules
@@ -3677,12 +3679,18 @@ function LocalPcWorkspaceMockup({
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => { const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) return if (agentActionControllerRef.current || agentApplying) return
try { try {
const modules = await collectAgentWorkspace() await requestAgentPlan(content, [], [], {
await requestAgentPlan(content, modules, [], {
attachments, attachments,
loadInitialContext: async (signal) => {
const modules = await readAgentModules()
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
return { modules: await collectAgentWorkspace([], modules), runtimeLogs: [] }
},
loadRequestedContext: loadAgentRequestedContext, loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool, executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local' environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
}) })
} catch (error) { } catch (error) {
console.error('[agent] workspace collection failed', error) console.error('[agent] workspace collection failed', error)
@@ -3824,7 +3832,9 @@ function LocalPcWorkspaceMockup({
const currentModules = await readAgentModules() const currentModules = await readAgentModules()
assertActive() assertActive()
await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], { await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], {
showUserMessage: false, environment, executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext, showUserMessage: false, environment, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview,
executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }] toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }]
}) })
} catch (error) { } catch (error) {
@@ -3906,7 +3916,9 @@ function LocalPcWorkspaceMockup({
showUserMessage: false, showUserMessage: false,
loadRequestedContext: loadAgentRequestedContext, loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool, executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local' environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
}) })
} catch (error) { } catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.') const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
@@ -4752,6 +4764,8 @@ function LocalPcWorkspaceMockup({
agentEnabled={agentEnabled} agentEnabled={agentEnabled}
agentProvider={agentProvider} agentProvider={agentProvider}
agentImagesEnabled={Boolean(agentImagesEnabled)} agentImagesEnabled={Boolean(agentImagesEnabled)}
agentModels={agentModels}
onAgentProviderChange={selectAgentProvider}
agentStatusLoading={agentStatusLoading} agentStatusLoading={agentStatusLoading}
agentPending={agentPending} agentPending={agentPending}
agentActivity={agentActivity} agentActivity={agentActivity}
@@ -0,0 +1,119 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Bot, Check } from 'lucide-react'
import { t } from '../i18n'
import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels'
export function AgentModelSelector({ models, provider, disabled, dark, onChange }: {
models: AgentModelOption[]
provider: string | null
disabled: boolean
dark: boolean
onChange: (provider: string) => void
}): React.JSX.Element {
const selected = models.find(model => model.provider === provider)
const blocked = disabled || models.length === 0
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({ left: 8, bottom: 8, width: 280, maxHeight: 320 })
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuId = useId()
const visible = open && !blocked
const close = (restoreFocus = false): void => {
setOpen(false)
if (restoreFocus) buttonRef.current?.focus({ preventScroll: true })
}
useEffect(() => {
if (blocked) setOpen(false)
}, [blocked])
useLayoutEffect(() => {
if (!visible || !buttonRef.current) return
const bounds = buttonRef.current.getBoundingClientRect()
const width = Math.min(320, window.innerWidth - 16)
setPosition({
left: Math.max(8, Math.min(bounds.left, window.innerWidth - width - 8)),
bottom: window.innerHeight - bounds.top + 6,
width,
maxHeight: Math.max(0, Math.min(320, bounds.top - 14))
})
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
const index = Math.max(0, models.findIndex(model => model.provider === provider))
items?.[index]?.focus({ preventScroll: true })
}, [visible, models, provider])
useEffect(() => {
if (!visible) return
const outside = (event: Event): void => {
const target = event.target as Node
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false)
}
const dismiss = (): void => setOpen(false)
window.addEventListener('pointerdown', outside, true)
window.addEventListener('contextmenu', outside)
window.addEventListener('scroll', outside, true)
window.addEventListener('resize', dismiss)
window.addEventListener('blur', dismiss)
return () => {
window.removeEventListener('pointerdown', outside, true)
window.removeEventListener('contextmenu', outside)
window.removeEventListener('scroll', outside, true)
window.removeEventListener('resize', dismiss)
window.removeEventListener('blur', dismiss)
}
}, [visible])
return (
<>
<button
ref={buttonRef}
type="button"
aria-label={t('에이전트 모델 선택')}
aria-haspopup="menu"
aria-expanded={visible}
aria-controls={visible ? menuId : undefined}
title={selected ? agentModelLabel(selected) : t('사용 가능한 모델 없음')}
disabled={blocked}
onClick={() => setOpen(current => !current)}
onKeyDown={event => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setOpen(true) }
else if (event.key === 'Escape') close()
}}
className={['grid size-7 shrink-0 cursor-pointer place-items-center rounded outline-none transition focus-visible:ring-1 focus-visible:ring-[#8b5cf6] disabled:cursor-default disabled:opacity-35',
dark ? 'text-white/60 hover:bg-white/10 hover:text-white/90' : 'text-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]',
visible ? dark ? 'bg-white/10 text-white/90' : 'bg-[#eef1f7] text-[#111520]' : ''].join(' ')}
>
<Bot size={15} aria-hidden="true" />
</button>
{visible && createPortal(
<div ref={menuRef} id={menuId} role="menu" aria-label={t('에이전트 모델 선택')}
className={['fixed z-[160] overflow-y-auto overscroll-contain rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'].join(' ')}
style={position}
onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}
onKeyDown={event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); return }
if (event.key === 'Tab') { close(true); return }
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
const current = items.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
: (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
items[next]?.focus()
}}
>
{models.map(model => <button key={model.provider} type="button" role="menuitemradio" aria-checked={model.provider === provider}
title={agentModelLabel(model)} tabIndex={-1}
onClick={() => { if (!blocked) { onChange(model.provider); close(true) } }}
className={['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left outline-none transition',
dark ? 'hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520] focus:bg-[#eef1f7] focus:text-[#111520]'].join(' ')}>
<Check size={13} className={['shrink-0', model.provider === provider ? '' : 'opacity-0'].join(' ')} aria-hidden="true" />
<span className="min-w-0 truncate">{agentModelLabel(model)}</span>
</button>)}
</div>, document.body
)}
</>
)
}
+5 -2
View File
@@ -298,6 +298,7 @@ export const QaInputBox = memo(function QaInputBox({
imagesEnabled = false, imagesEnabled = false,
onClearConversation, onClearConversation,
clearDisabled = false, clearDisabled = false,
footerAction,
onStop, onStop,
onSubmit onSubmit
}: { }: {
@@ -309,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({
imagesEnabled?: boolean imagesEnabled?: boolean
onClearConversation?: () => void onClearConversation?: () => void
clearDisabled?: boolean clearDisabled?: boolean
footerAction?: React.ReactNode
onStop: () => void onStop: () => void
onSubmit: (content: string, attachments?: ChatAttachment[]) => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void
}): React.JSX.Element { }): React.JSX.Element {
@@ -456,8 +458,9 @@ export const QaInputBox = memo(function QaInputBox({
)} )}
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} /> <ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>} {attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
{(attachmentsEnabled || onClearConversation) && ( {(attachmentsEnabled || onClearConversation || footerAction) && (
<div className={['flex h-9 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}> <div className={['flex h-9 min-w-0 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
{footerAction}
{attachmentsEnabled && ( {attachmentsEnabled && (
<> <>
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => { <input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
@@ -10,6 +10,8 @@ import { ChatImageAttachments } from '../../components/ChatImageAttachments'
import { AgentProgressBubble } from '../../components/AgentProgressBubble' import { AgentProgressBubble } from '../../components/AgentProgressBubble'
import { AgentWorkDuration } from '../../components/AgentWorkDuration' import { AgentWorkDuration } from '../../components/AgentWorkDuration'
import { AgentCodeReferences } from '../../components/AgentCodeReferences' import { AgentCodeReferences } from '../../components/AgentCodeReferences'
import { AgentModelSelector } from '../../components/AgentModelSelector'
import type { AgentModelOption } from './agentModels'
import type { AgentProgressStep } from './agentContext' import type { AgentProgressStep } from './agentContext'
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets' import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent' import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
@@ -47,6 +49,8 @@ type WorkspaceQaPanelProps = {
agentEnabled: boolean agentEnabled: boolean
agentProvider?: string | null agentProvider?: string | null
agentImagesEnabled?: boolean agentImagesEnabled?: boolean
agentModels?: AgentModelOption[]
onAgentProviderChange?: (provider: string) => void
agentStatusLoading: boolean agentStatusLoading: boolean
agentPending: boolean agentPending: boolean
agentActivity?: string agentActivity?: string
@@ -124,6 +128,8 @@ export default function WorkspaceQaPanel({
agentEnabled, agentEnabled,
agentProvider, agentProvider,
agentImagesEnabled = false, agentImagesEnabled = false,
agentModels = [],
onAgentProviderChange,
agentStatusLoading, agentStatusLoading,
agentPending, agentPending,
agentActivity, agentActivity,
@@ -348,6 +354,7 @@ export default function WorkspaceQaPanel({
imagesEnabled={qaMode === 'agent' && agentImagesEnabled} imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
onClearConversation={requestClearConversation} onClearConversation={requestClearConversation}
clearDisabled={clearDisabled} clearDisabled={clearDisabled}
footerAction={qaMode === 'agent' && onAgentProviderChange ? <AgentModelSelector models={agentModels} provider={agentProvider ?? null} dark={dark} disabled={inputBusy || agentStatusLoading || inputDisabled} onChange={onAgentProviderChange} /> : undefined}
/> />
{clearConfirmationOpen && createPortal( {clearConfirmationOpen && createPortal(
<ConfirmStatusModal <ConfirmStatusModal
@@ -1,7 +1,8 @@
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent' import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { compactAgentEvidence, describeAgentEvidence, missingAgentRead, readFileEvidence } from './agentEvidence'
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] } export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[]; contextNotes?: string[] }
export type AgentContextRequest = { modules: string[]; logs: string[] } export type AgentContextRequest = { modules: string[]; logs: string[] }
export type AgentProgressStep = { export type AgentProgressStep = {
id: string id: string
@@ -49,11 +50,11 @@ export function recoverInterruptedAgentMessages(messages: AgentConversationMessa
export function agentPlanRequestText(request: string, finalize = false): string { export function agentPlanRequestText(request: string, finalize = false): string {
if (!finalize) return request if (!finalize) return request
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n' const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 코드 위치와 역할 설명은 전체 파일이 아닌 확인된 줄 범위만으로 답할 수 있습니다. 확인된 내용은 codeReferences와 함께 답하고 미확인 항목만 별도로 구분하세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
return instruction + request.slice(0, 4000 - instruction.length) return instruction + request.slice(0, 4000 - instruction.length)
} }
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = []): AgentContext { function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = [], preferredReads: AgentToolRequest[] = []): AgentContext {
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name))) const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
const selected = new Map<string, AgentWorkspaceModule['files']>() const selected = new Map<string, AgentWorkspaceModule['files']>()
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 })) const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
@@ -111,12 +112,28 @@ function fitAgentContext(context: AgentContext, preferredModules: string[] = [],
return { ...log, content, ...(notes ? { message: notes } : {}) } return { ...log, content, ...(notes ? { message: notes } : {}) }
}) })
let toolSize = 0 let toolSize = 0
const toolResults = [...(context.toolResults ?? [])].reverse().filter(result => { const evidence = compactAgentEvidence(context.toolResults ?? [])
if (result.data.length > 60_000 || toolSize + result.data.length > 180_000) return false const priority = (result: AgentToolResult): number => {
const file = readFileEvidence(result)
if (file && preferredReads.some(tool => tool.name === 'files.read' && tool.moduleName === result.moduleName && tool.args?.path === file.path)) return 6
return result.name === 'action.result' ? 5 : !result.name.startsWith('files.') ? 4
: file || result.status === 'error' ? 3 : 1
}
const candidatesByPriority = evidence.map((result, index) => ({ result, index }))
.sort((a, b) => priority(b.result) - priority(a.result) || b.index - a.index)
const retained = new Set<AgentToolResult>()
for (const { result } of candidatesByPriority) {
if (retained.size >= 12 || result.data.length > 60_000 || toolSize + result.data.length > 180_000) continue
retained.add(result)
toolSize += result.data.length toolSize += result.data.length
return true }
}).slice(0, 12).reverse() const toolResults = evidence.filter(result => retained.has(result))
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) } const omittedTools = evidence.filter(result => !retained.has(result))
const contextNotes = omittedTools.length
? [`용량·개수 한도로 조회 근거 ${omittedTools.length}개를 제외했습니다. 제외된 내용을 확인했다고 주장하지 마세요.`,
...omittedTools.slice(-10).map(result => describeAgentEvidence(result).slice(0, 500))]
: []
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults, contextNotes } : {}) }
} }
export async function resolveAgentContext({ export async function resolveAgentContext({
@@ -142,6 +159,7 @@ export async function resolveAgentContext({
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName)) const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
const queriedTools = new Set<string>() const queriedTools = new Set<string>()
let toolCount = 0 let toolCount = 0
let confirmedAnswer: AgentPlanResponse | undefined
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))]) const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
const usage: NonNullable<AgentConversationMessage['usage']> = {} const usage: NonNullable<AgentConversationMessage['usage']> = {}
const checkAborted = (): void => { const checkAborted = (): void => {
@@ -162,9 +180,17 @@ export async function resolveAgentContext({
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => ( const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => (
`- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}` `- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}`
)).join('\n').slice(0, 8000) )).join('\n').slice(0, 8000)
const toolStatus = (context.toolResults ?? []).map(result => `- ${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`).join('\n').slice(0, 5000) const toolStatus = (context.toolResults ?? []).map(result => `- ${describeAgentEvidence(result)}`).join('\n').slice(0, 8000)
const candidate = response.plan.codeReferences?.length ? response : confirmedAnswer
const references = candidate?.plan.codeReferences?.filter(reference => {
const latest = (cachedContext.toolResults ?? []).filter(result => result.moduleName === reference.moduleName)
.map(readFileEvidence).filter(file => file?.path === reference.path).at(-1)
return !latest || latest.exists && latest.hash === reference.expectedHash
})
const confirmed = references?.length && candidate ? { ...candidate, plan: { ...candidate.plan, codeReferences: references } } : undefined
const confirmedReferences = Boolean(confirmed?.plan.codeReferences?.length)
const explanation = stillRequesting const explanation = stillRequesting
? `AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.` ? `${confirmedReferences ? `${confirmed!.plan.explanation}\n\n추가 분석은 완료하지 못했지만 확인된 코드 위치는 유지했습니다.` : 'AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.'}\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`.slice(0, 12_000)
: response.plan.explanation : response.plan.explanation
return { return {
...response, ...response,
@@ -172,7 +198,8 @@ export async function resolveAgentContext({
// Recovery is analysis-only, even if the model proposes actions despite the instruction. // Recovery is analysis-only, even if the model proposes actions despite the instruction.
plan: { plan: {
...response.plan, ...response.plan,
summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary, summary: stillRequesting ? confirmedReferences ? '추가 분석은 미완료이며 확인된 코드 위치를 안내합니다.' : '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
...(stillRequesting ? { codeReferences: confirmed?.plan.codeReferences ?? [] } : {}),
explanation, explanation,
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
} }
@@ -186,19 +213,31 @@ export async function resolveAgentContext({
const response = await requestPlan(context) const response = await requestPlan(context)
checkAborted() checkAborted()
addUsage(response) addUsage(response)
if (response.plan.codeReferences?.length) confirmedAnswer = response
const modules = [...new Set(response.plan.requestedModules ?? [])] const modules = [...new Set(response.plan.requestedModules ?? [])]
const logs = [...new Set(response.plan.requestedLogs ?? [])] const logs = [...new Set(response.plan.requestedLogs ?? [])]
const tools = response.plan.toolRequests ?? [] const tools = response.plan.toolRequests ?? []
onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length }) onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length })
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } } if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
if (round === maxRounds) return finalize() if (round === maxRounds) {
context = fitAgentContext(cachedContext, [], [], tools)
return finalize()
}
if (tools.length) { if (tools.length) {
if (!executeTool || tools.length > 6) return finalize() if (!executeTool || tools.length > 6) return finalize()
const fresh = tools.filter(tool => !queriedTools.has(fingerprint(tool))) const fresh = tools.filter(tool => {
if (!fresh.length || toolCount + fresh.length > 24) return finalize() const missing = missingAgentRead(tool, cachedContext.toolResults ?? [])
return missing && !queriedTools.has(fingerprint(missing))
})
if (!fresh.length || toolCount + fresh.length > 24) {
context = fitAgentContext(cachedContext, [], [], tools)
return finalize()
}
const results = [...(cachedContext.toolResults ?? [])] const results = [...(cachedContext.toolResults ?? [])]
for (const [index, tool] of fresh.entries()) { for (const [index, proposedTool] of fresh.entries()) {
checkAborted() checkAborted()
const tool = missingAgentRead(proposedTool, results)
if (!tool) continue
const key = fingerprint(tool) const key = fingerprint(tool)
if (queriedTools.has(key)) continue if (queriedTools.has(key)) continue
queriedTools.add(key) queriedTools.add(key)
@@ -209,8 +248,8 @@ export async function resolveAgentContext({
toolCount++ toolCount++
results.push(result) results.push(result)
} }
cachedContext = { ...cachedContext, toolResults: results } cachedContext = { ...cachedContext, toolResults: compactAgentEvidence(results) }
context = fitAgentContext(cachedContext) context = fitAgentContext(cachedContext, [], [], tools)
onProgress?.('조회 결과 분석 중') onProgress?.('조회 결과 분석 중')
// A model may also use legacy source requests; process those on the next pass. // A model may also use legacy source requests; process those on the next pass.
continue continue
@@ -0,0 +1,74 @@
import type { AgentConversationMessage } from './useAiAgent'
export type AgentConversationScope = { workspace: string; createdAt: number }
export type AgentConversationContext = {
environment: 'local' | 'remote'
workspaceTarget: string
summary: string
recentMessages: Array<{ role: 'user' | 'assistant'; content: string }>
}
const MAX_AGE_MS = 24 * 60 * 60 * 1000
const RECENT_TURNS = 2
const MESSAGE_LIMIT = 700
const SUMMARY_LIMIT = 1000
const clip = (text: string, limit: number): string => text.length > limit ? `${text.slice(0, limit - 3)}...` : text
export function agentConversationWorkspace(environment?: 'local' | 'remote', target?: string, root?: string): string | undefined {
if (!environment || !target || !root) return undefined
const workspace = JSON.stringify([environment, target, root])
return workspace.length <= 1000 ? workspace : undefined
}
function responseContext(message: AgentConversationMessage, limit: number): string {
const plan = message.plan!
// Keep identities and outcomes, never replay source contents, logs, or executable payloads.
const details = [
...(plan.codeReferences ?? []).slice(0, 3).map(item => `${item.moduleName}/${item.path}:${item.startLine}-${item.endLine}`),
...plan.changes.slice(0, 2).map(item => `${item.moduleName}/${item.path} (file change: ${message.applyState ?? 'ready'})`),
...plan.commands.slice(0, 2).map(item => `${item.moduleName} (${clip(item.purpose, 60)}: ${message.commandExecutions?.[item.id]?.status ?? 'pending'})`)
].join('; ')
const targets = details ? `\nHistorical references/outcomes: ${clip(details, Math.min(240, Math.floor(limit / 2)))}` : ''
const answer = (limit > 300 ? `${plan.summary}\n${plan.explanation}` : plan.summary).replace(/```[^\n]*\n[\s\S]*?(?:```|$)/g, '[code block omitted]')
return `${clip(answer.trim(), limit - targets.length)}${targets}`
}
export function buildAgentConversationContext(
messages: AgentConversationMessage[],
workspace: string | undefined,
now = Date.now()
): AgentConversationContext | undefined {
if (!workspace) return undefined
const [environment, workspaceTarget] = JSON.parse(workspace) as ['local' | 'remote', string, string]
const turns: Array<{ user: AgentConversationMessage; assistant: AgentConversationMessage }> = []
let summary = ''
let user: AgentConversationMessage | undefined
const inScope = (message: AgentConversationMessage): boolean => message.conversationScope?.workspace === workspace
&& Number.isFinite(message.conversationScope.createdAt)
&& message.conversationScope.createdAt <= now
&& now - message.conversationScope.createdAt < MAX_AGE_MS
for (const message of messages) {
if (message.role === 'user') {
user = inScope(message) ? message : undefined
continue
}
if (!user || !inScope(message) || !message.plan || message.plan.toolRequests?.length
|| message.plan.requestedModules.length || message.plan.requestedLogs.length) continue
const previous = turns.at(-1)
if (previous?.user === user) previous.assistant = message
else turns.push({ user, assistant: message })
if (turns.length > RECENT_TURNS) turns.shift()
if (message.plan.conversationSummary?.trim()) summary = clip(message.plan.conversationSummary.trim(), SUMMARY_LIMIT)
}
if (!turns.length) return undefined
const recent = turns.slice(-RECENT_TURNS)
return {
environment, workspaceTarget, summary,
recentMessages: recent.flatMap(turn => [
{ role: 'user' as const, content: clip(turn.user.content, MESSAGE_LIMIT) },
{ role: 'assistant' as const, content: responseContext(turn.assistant, MESSAGE_LIMIT) }
])
}
}
@@ -0,0 +1,107 @@
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
type FileEvidence = {
path: string; exists: boolean; content: string; hash: string | null
complete: boolean; startLine: number; endLine: number; totalLines: number
contentTruncated?: boolean
}
export function readFileEvidence(result: AgentToolResult): FileEvidence | undefined {
if (result.name !== 'files.read' || result.status !== 'ok') return
try {
const data = JSON.parse(result.data)
if (typeof data.path !== 'string' || typeof data.content !== 'string' || typeof data.complete !== 'boolean'
|| data.contentTruncated !== undefined && typeof data.contentTruncated !== 'boolean') return
if (data.exists === false && data.complete === true && data.hash === null && data.content === '') {
return { ...data, startLine: 1, endLine: 0, totalLines: 0 }
}
if (data.exists !== true || typeof data.hash !== 'string' || !/^[a-f0-9]{64}$/.test(data.hash)) return
const startLine = data.startLine ?? 1
const last = startLine + data.content.split('\n').length - 1
// Older clients cannot distinguish a ranged read from a cut final line.
const endLine = data.endLine ?? last - Number(result.truncated)
if (!Number.isSafeInteger(startLine) || startLine < 1 || !Number.isSafeInteger(endLine)
|| endLine < startLine - 1 || endLine > last || !Number.isSafeInteger(data.totalLines) || data.totalLines < 1 || endLine > data.totalLines
|| data.contentTruncated === true && endLine >= last) return
return { ...data, startLine, endLine }
} catch { return }
}
const fileKey = (result: AgentToolResult, file: FileEvidence): string => JSON.stringify([result.target, result.moduleName, file.path])
// Only combine observations of the same file revision on the same host.
export function compactAgentEvidence(results: AgentToolResult[]): AgentToolResult[] {
const compacted: AgentToolResult[] = []
for (const result of results) {
const file = readFileEvidence(result)
if (!file) { compacted.push(result); continue }
const key = fileKey(result, file)
let current = result
let currentFile = file
for (let index = compacted.length - 1; index >= 0; index--) {
const previous = compacted[index]
const prior = readFileEvidence(previous)
if (!prior || fileKey(previous, prior) !== key) continue
if (prior.hash !== file.hash || prior.exists !== file.exists) {
compacted.splice(index, 1)
continue
}
if (currentFile.complete) { compacted.splice(index, 1); continue }
if (prior.complete) { current = previous; currentFile = prior; compacted.splice(index, 1); continue }
if (prior.contentTruncated !== false || currentFile.contentTruncated !== false
|| prior.totalLines !== currentFile.totalLines
|| prior.endLine + 1 < currentFile.startLine || currentFile.endLine + 1 < prior.startLine) continue
const lines = new Map<number, string>()
let conflict = false
for (const part of [prior, currentFile]) {
for (const [offset, line] of part.content.split('\n').entries()) {
const number = part.startLine + offset
if (number > part.endLine) break
if (lines.has(number) && lines.get(number) !== line) conflict = true
lines.set(number, line)
}
}
if (conflict) continue
const startLine = Math.min(prior.startLine, currentFile.startLine)
const endLine = Math.max(prior.endLine, currentFile.endLine)
const content = Array.from({ length: endLine - startLine + 1 }, (_, offset) => lines.get(startLine + offset)!).join('\n')
// Merged ranges remain read-only evidence: no unverified full-file hash promotion.
const merged = { ...currentFile, startLine, endLine, content, complete: false, contentTruncated: false }
const data = JSON.stringify(merged)
if (content.length > 40_000 || data.length > 60_000) continue
currentFile = merged
current = { ...result, data, truncated: true }
compacted.splice(index, 1)
}
compacted.push(current)
}
return compacted
}
export function missingAgentRead(tool: AgentToolRequest, results: AgentToolResult[]): AgentToolRequest | undefined {
if (tool.name !== 'files.read') return tool
const observations = results.filter(result => result.moduleName === tool.moduleName)
.flatMap(result => { const file = readFileEvidence(result); return file && file.path === tool.args?.path ? [{ file, target: result.target }] : [] })
const latest = observations.at(-1)
const files = observations.filter(item => item.target === latest?.target && item.file.hash === latest?.file.hash).map(item => item.file)
if (files.some(file => file.complete)) return
// An unbounded read may be needed for an approved edit; partial ranges do not replace it.
if (tool.args?.startLine === undefined && tool.args?.lineCount === undefined) return tool
const start = tool.args?.startLine ?? 1
const total = files[0]?.totalLines
const end = Math.min(start + (tool.args?.lineCount ?? 200) - 1, total ?? Infinity)
let next = start
const ranges = files.map(file => [file.startLine, file.endLine]).sort((a, b) => a[0] - b[0])
for (const [from, to] of ranges) {
if (from > next) return { ...tool, args: { ...tool.args, startLine: next, lineCount: Math.min(end, from - 1) - next + 1 } }
if (to >= next) next = to + 1
if (next > end) return
}
if (next > end) return
return { ...tool, args: { ...tool.args, startLine: next, lineCount: end - next + 1 } }
}
export function describeAgentEvidence(result: AgentToolResult): string {
const file = readFileEvidence(result)
return `${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${file ? ` · ${file.path}${file.exists ? ` · ${file.startLine}–${file.endLine}/${file.totalLines}줄` : ' · 파일 없음'}` : ''}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`
}
@@ -0,0 +1,34 @@
export const AGENT_PROVIDERS = ['A_COPILOT', 'OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const
export type AgentProvider = typeof AGENT_PROVIDERS[number]
export type AgentModelOption = { provider: AgentProvider; model: string; imagesEnabled: boolean }
export type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
imagesEnabled?: boolean
models: AgentModelOption[]
selectionSupported: boolean
}
export const AGENT_PROVIDER_LABELS: Record<AgentProvider, string> = {
A_COPILOT: 'A-Copilot', OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen'
}
export function normalizeAgentStatus(value: Partial<AgentStatus>): AgentStatus {
const selectionSupported = Array.isArray(value.models)
const candidates = value.enabled ? selectionSupported ? value.models! : [value] : []
const models = AGENT_PROVIDERS.flatMap(provider => {
const item = candidates.find(item => item.provider === provider && typeof item.model === 'string' && item.model.trim() && item.model.length <= 120)
return item ? [{ provider, model: item.model!.trim(), imagesEnabled: provider === 'QWEN' && item.imagesEnabled === true }] : []
})
return { enabled: models.length > 0, provider: models[0]?.provider ?? null, model: models[0]?.model ?? null,
imagesEnabled: models[0]?.imagesEnabled ?? false, models, selectionSupported }
}
export function selectedAgentModel(models: AgentModelOption[], preference: string | null): AgentModelOption | null {
return models.find(model => model.provider === preference) ?? models[0] ?? null
}
export function agentModelLabel(model: AgentModelOption): string {
return model.provider === 'A_COPILOT' ? 'A-Copilot' : `${AGENT_PROVIDER_LABELS[model.provider]} · ${model.model}`
}
@@ -3,6 +3,8 @@ import { createAgentSessionSync } from './agentSessionSync'
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext' import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext'
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments' import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
import { agentConversationWorkspace, buildAgentConversationContext, type AgentConversationScope } from './agentConversationContext'
export type AgentFileChange = { export type AgentFileChange = {
expectedHash?: string | null expectedHash?: string | null
@@ -53,6 +55,7 @@ export type AgentCommandExecution = {
} }
export type AgentPlan = { export type AgentPlan = {
conversationSummary?: string
codeReferences?: AgentCodeReference[] codeReferences?: AgentCodeReference[]
toolRequests?: AgentToolRequest[] toolRequests?: AgentToolRequest[]
evidence?: AgentToolResult[] evidence?: AgentToolResult[]
@@ -87,6 +90,7 @@ export type AgentRuntimeLog = {
} }
export type AgentConversationMessage = { export type AgentConversationMessage = {
conversationScope?: AgentConversationScope
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
@@ -117,13 +121,6 @@ type UseAiAgentOptions = {
onResponse?: () => void onResponse?: () => void
} }
type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
imagesEnabled?: boolean
}
type AgentSessionResponse = { type AgentSessionResponse = {
sessionId: string | null sessionId: string | null
messages: AgentConversationMessage[] messages: AgentConversationMessage[]
@@ -132,7 +129,11 @@ type AgentSessionResponse = {
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>() const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) { export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) {
const [status, setStatus] = useState<AgentStatus>({ enabled: false, provider: null, model: null }) const modelPreferenceKey = `autonomy-studio.lms.agent-model:${JSON.stringify([apiBaseUrl, studentEmail.toLowerCase(), courseId])}`
const [statusSnapshot, setStatusSnapshot] = useState<{ scope: string; status: AgentStatus }>({ scope: '', status: normalizeAgentStatus({}) })
const status = statusSnapshot.scope === modelPreferenceKey ? statusSnapshot.status : normalizeAgentStatus({})
const [modelPreference, setModelPreference] = useState<{ scope: string; provider: string | null }>({ scope: '', provider: null })
const selectedModel = selectedAgentModel(status.models, modelPreference.scope === modelPreferenceKey ? modelPreference.provider : null)
const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId)) const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId)) const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
const [historyError, setHistoryError] = useState<string | null>(null) const [historyError, setHistoryError] = useState<string | null>(null)
@@ -150,6 +151,18 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
onResponseRef.current = onResponse onResponseRef.current = onResponse
const authenticated = Boolean(accessToken && courseId && studentEmail) const authenticated = Boolean(accessToken && courseId && studentEmail)
const selectProvider = (provider: string): void => {
if (pending || applyingRef.current || abortControllerRef.current || !status.models.some(model => model.provider === provider)) return
setModelPreference({ scope: modelPreferenceKey, provider })
try { window.localStorage.setItem(modelPreferenceKey, provider) } catch { /* Selection still works without persistent storage. */ }
}
useEffect(() => {
let provider: string | null = null
try { provider = window.localStorage.getItem(modelPreferenceKey) } catch { /* Use the first available model. */ }
setModelPreference({ scope: modelPreferenceKey, provider })
}, [modelPreferenceKey])
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => { const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
const authorization = new Headers(options?.headers).get('Authorization') const authorization = new Headers(options?.headers).get('Authorization')
?? `Bearer ${await tokenReaderRef.current()}` ?? `Bearer ${await tokenReaderRef.current()}`
@@ -167,7 +180,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) { if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.') throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
} }
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.') if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens|conversationContext) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.') throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
} }
return response.json() as Promise<T> return response.json() as Promise<T>
@@ -205,27 +218,43 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
useEffect(() => { useEffect(() => {
if (!accessToken || !courseId) { if (!accessToken || !courseId) {
setStatus({ enabled: false, provider: null, model: null }) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
setStatusLoading(false) setStatusLoading(false)
return return
} }
let cancelled = false let cancelled = false
setStatusLoading(true) let revision = 0
let initialLoad = true
const refresh = (): void => {
const requestRevision = ++revision
if (initialLoad) setStatusLoading(true)
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`) requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
.then((nextStatus) => { .then(nextStatus => {
if (!cancelled) setStatus(nextStatus) if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus(nextStatus) })
}) })
.catch(() => { .catch(() => {
if (!cancelled) setStatus({ enabled: false, provider: null, model: null }) if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
}) })
.finally(() => { .finally(() => {
if (!cancelled) setStatusLoading(false) if (!cancelled && requestRevision === revision) {
initialLoad = false
setStatusLoading(false)
}
}) })
}
const refreshVisible = (): void => { if (!document.hidden) refresh() }
refresh()
window.addEventListener('focus', refresh)
document.addEventListener('visibilitychange', refreshVisible)
const timer = window.setInterval(refreshVisible, 30_000)
return () => { return () => {
cancelled = true cancelled = true
window.removeEventListener('focus', refresh)
document.removeEventListener('visibilitychange', refreshVisible)
window.clearInterval(timer)
} }
}, [accessToken, courseId, requestApi, weekNumber]) }, [accessToken, courseId, modelPreferenceKey, requestApi, weekNumber])
// The workspace stays mounted while other app pages or Q&A tabs are shown. // The workspace stays mounted while other app pages or Q&A tabs are shown.
// Request cancellation follows the conversation lifetime, not its visibility. // Request cancellation follows the conversation lifetime, not its visibility.
@@ -290,19 +319,28 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
options: { options: {
showUserMessage?: boolean showUserMessage?: boolean
attachments?: ChatAttachment[] attachments?: ChatAttachment[]
loadInitialContext?: (signal: AbortSignal) => Promise<AgentContext>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext> loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult> executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
environment?: 'local' | 'remote' environment?: 'local' | 'remote'
workspaceTarget?: string
workspaceRoot?: string
toolResults?: AgentToolResult[] toolResults?: AgentToolResult[]
} = {} } = {}
): Promise<void> => { ): Promise<void> => {
const request = rawRequest.trim() const request = rawRequest.trim()
if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return if (!request || pending || historyLoading || statusLoading || abortControllerRef.current || !status.enabled || !selectedModel) return
const requestModel = selectedModel
const selection = status.selectionSupported ? { provider: requestModel.provider } : {}
const startedAt = Date.now() const startedAt = Date.now()
const workspace = agentConversationWorkspace(options.environment, options.workspaceTarget, options.workspaceRoot)
const conversationScope = workspace ? { workspace, createdAt: startedAt } : undefined
const conversationContext = buildAgentConversationContext(messageStateRef.current.messages, workspace, startedAt)
const userMessage: AgentConversationMessage = { const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`, id: `agent-user-${Date.now()}`,
role: 'user', role: 'user',
content: request content: request,
...(conversationScope ? { conversationScope } : {})
} }
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage]) if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true) setPending(true)
@@ -317,26 +355,34 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} }
try { try {
let initialContext: AgentContext = { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }
if (options.loadInitialContext) {
setActivity('모듈 상태 조회 중')
const loaded = await options.loadInitialContext(abortController.signal)
if (!currentRequest()) return
initialContext = { ...initialContext, ...loaded }
}
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }]) const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
const imageAttachments: AgentImageReference[] = [] const imageAttachments: AgentImageReference[] = []
for (const file of options.attachments ?? []) { for (const file of options.attachments ?? []) {
if (file.kind !== 'image') continue if (file.kind !== 'image') continue
if (!status.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.') if (!requestModel.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
setActivity(`이미지 업로드 중: ${file.name}`) setActivity(`이미지 업로드 중: ${file.name}`)
const body = new FormData() const body = new FormData()
body.append('courseId', courseId) body.append('courseId', courseId)
if (selection.provider) body.append('provider', selection.provider)
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber)) if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
body.append('file', file.file, file.name) body.append('file', file.file, file.name)
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal }) const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
if (!currentRequest()) return
imageAttachments.push(uploaded) imageAttachments.push(uploaded)
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item)) if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
if (abortController.signal.aborted) return
appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name }) appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
} }
const imageTokens = imageAttachments.map(image => image.token) const imageTokens = imageAttachments.map(image => image.token)
setActivity('요청 분석 중') setActivity('요청 분석 중')
const response = await resolveAgentContext({ const response = await resolveAgentContext({
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }, initialContext,
signal: abortController.signal, signal: abortController.signal,
loadRequestedContext: options.loadRequestedContext, loadRequestedContext: options.loadRequestedContext,
executeTool: options.executeTool, executeTool: options.executeTool,
@@ -345,10 +391,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', { requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST', method: 'POST',
signal: abortController.signal, signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context }) body: JSON.stringify({ courseId, week: weekNumber, ...selection, request: agentPlanRequestText(request, finalize), environment: options.environment, workspaceTarget: options.workspaceTarget, ...(options.executeTool ? { toolProtocol: 1 } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context, ...(conversationContext ? { conversationContext } : {}) })
}) })
}) })
if (abortController.signal.aborted) return if (!currentRequest()) return
const plan: AgentPlan = { const plan: AgentPlan = {
...response.plan, ...response.plan,
requestedModules: response.plan.requestedModules ?? [], requestedModules: response.plan.requestedModules ?? [],
@@ -363,6 +409,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
revealStartedAt: Date.now(), revealStartedAt: Date.now(),
durationMs: Math.max(0, Date.now() - startedAt), durationMs: Math.max(0, Date.now() - startedAt),
content: plan.summary, content: plan.summary,
...(conversationScope ? { conversationScope } : {}),
provider: response.provider, provider: response.provider,
model: response.model, model: response.model,
usage: response.usage, usage: response.usage,
@@ -372,7 +419,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}]) }])
onResponseRef.current?.() onResponseRef.current?.()
} catch (error) { } catch (error) {
if (abortController.signal.aborted) return if (!currentRequest()) return
updateMessages((current) => [...current, { updateMessages((current) => [...current, {
id: `agent-error-${Date.now()}`, id: `agent-error-${Date.now()}`,
role: 'assistant', role: 'assistant',
@@ -504,6 +551,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
return { return {
...status, ...status,
provider: selectedModel?.provider ?? null,
model: selectedModel?.model ?? null,
imagesEnabled: selectedModel?.imagesEnabled ?? false,
selectProvider,
statusLoading: statusLoading || historyLoading, statusLoading: statusLoading || historyLoading,
pending, pending,
activity, activity,
+3
View File
@@ -1,4 +1,6 @@
{ {
"에이전트 모델 선택": "Agent model",
"사용 가능한 모델 없음": "No models available",
"전송": "Send", "전송": "Send",
"파일 첨부": "Attach files", "파일 첨부": "Attach files",
"첨부 제거": "Remove attachment", "첨부 제거": "Remove attachment",
@@ -739,6 +741,7 @@
"소스 파일": "Source files", "소스 파일": "Source files",
"{{count}}건": "{{count}} items", "{{count}}건": "{{count}} items",
"요청 분석 중": "Analyzing request", "요청 분석 중": "Analyzing request",
"모듈 상태 조회 중": "Checking module status",
"조회 결과 분석 중": "Analyzing retrieved results", "조회 결과 분석 중": "Analyzing retrieved results",
"추가 자료 조회 중": "Retrieving additional context", "추가 자료 조회 중": "Retrieving additional context",
"최종 답변 정리 중": "Preparing the final answer", "최종 답변 정리 중": "Preparing the final answer",