feat: improve agent responses and workspace UI

Add live agent progress, elapsed timing, verified code navigation and rich Markdown rendering. Preserve NanumSquare styling with missing glyph fallback, fix platform availability filtering and unify workspace scrollbar and visualization controls.
This commit is contained in:
2026-10-19 17:06:18 +09:00
parent af21fa0510
commit 8b501266ff
33 changed files with 2231 additions and 98 deletions
+157
View File
@@ -0,0 +1,157 @@
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')
function compile(source, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports, DOMException, Error, ...globals })
return exports
}
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.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 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 plain = value => JSON.parse(JSON.stringify(value))
const response = (toolRequests = []) => ({ provider: 'QWEN', model: 'fixture', plan: { summary: '분석 완료', explanation: '최종 설명', requestedModules: [], requestedLogs: [], changes: [], commands: [], createModule: null, actionPlan: [], toolRequests } })
const deferred = () => { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
const tick = () => new Promise(resolve => setImmediate(resolve))
const result = (extra = {}) => ({ id: 'tool', name: 'files.read', status: 'ok', target: 'fixture', observedAt: new Date().toISOString(), data: '{"exists":true,"complete":true,"content":"PRIVATE_SOURCE"}', truncated: false, ...extra })
function harness(requestApi) {
const session = {}
let now = 1000
class Clock extends Date { static now() { return now } }
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
const messageStateRef = { current: { session } }
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
...context, session, messageStateRef, AbortController,
Date: Clock,
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false,
status: { enabled: true }, abortControllerRef: { current: null },
setPending: value => { state.pending = value },
setRequestStartedAt: value => { state.requestStartedAt = value },
setActivity: value => { state.activity = 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)) },
onResponseRef: { current: () => { state.notices++ } }, requestApi
})
return { ...api, state, messageStateRef, advance: ms => { now += ms } }
}
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
const first = deferred(), tool = deferred(), final = deferred()
let calls = 0
const h = harness(() => (++calls === 1 ? first.promise : final.promise))
const running = h.requestPlan('카메라 FPS 확인', [{ name: 'Camera', files: [] }], [], { executeTool: () => tool.promise })
assert.equal(h.state.pending, true)
assert.equal(h.state.requestStartedAt, 1000)
assert.equal(h.state.steps.length, 0)
first.resolve(response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config/camera.yaml' } }]))
await tick()
assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis'])
assert.match(h.state.activity, /files.read 조회 중/)
tool.resolve(result())
await tick()
assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis', 'tool'])
assert.equal(h.state.steps[1].path, 'config/camera.yaml')
assert.equal(JSON.stringify(h.state.steps).includes('PRIVATE_SOURCE'), false)
assert.equal(h.state.messages.length, 1)
assert.equal(h.state.saved.flat().some(message => message.progressSteps), false)
assert.equal(calls, 2)
h.advance(20_900)
final.resolve(response())
await running
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.pending, false)
assert.equal(h.state.activity, '')
assert.equal(h.state.requestStartedAt, null)
assert.equal(h.state.messages.length, 2)
assert.equal(h.state.messages[1].plan.summary, '분석 완료')
assert.equal(h.state.messages[1].durationMs, 20_900)
assert.equal(h.state.saved.at(-1)[1].durationMs, 20_900)
assert.equal(h.state.notices, 1)
})
test('errors replace temporary progress with the existing error reply', async () => {
let calls = 0
const h = harness(async () => {
if (++calls === 1) return response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }])
throw Error('서버 오류')
})
await h.requestPlan('확인', [{ name: 'Camera', files: [] }], [], { executeTool: async () => result({ status: 'error' }) })
assert.equal(h.state.messages.at(-1).content, '서버 오류')
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.pending, false)
assert.equal(h.state.requestStartedAt, null)
})
test('stopping clears progress and a late response cannot overwrite a newer request', async () => {
const old = deferred(), next = deferred()
let calls = 0
const h = harness(() => (++calls === 1 ? old.promise : next.promise))
const first = h.requestPlan('첫 질문', [], [])
h.stop()
assert.equal(h.state.requestStartedAt, null)
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.activity, '')
const second = h.requestPlan('다음 질문', [], [])
old.resolve(response())
await first
assert.equal(h.state.pending, true)
assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 0)
next.resolve(response())
await second
assert.equal(h.state.pending, false)
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 1)
})
test('scope changes suppress late progress callbacks', async () => {
const tool = deferred()
const h = harness(async () => response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }]))
const running = h.requestPlan('확인', [], [], { executeTool: () => tool.promise })
await tick()
h.messageStateRef.current.session = {}
const previous = h.state.steps.length
h.stop()
tool.resolve(result())
await running
assert.equal(previous, 1)
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.notices, 0)
})
test('source context stages report missing and limited modules and empty logs honestly', async () => {
const steps = []
let calls = 0
await context.resolveAgentContext({
signal: new AbortController().signal,
initialContext: { modules: [{ name: 'Missing', files: [] }, { name: 'Limited', files: [] }], runtimeLogs: [] },
onStep: step => steps.push(step),
requestPlan: async () => ++calls === 1 ? { ...response(), plan: { ...response().plan, requestedModules: ['Missing', 'Limited'], requestedLogs: ['Missing'] } } : response(),
loadRequestedContext: async () => ({ modules: [{ name: 'Missing', files: [], fileContext: { status: 'uninstalled' } }, { name: 'Limited', files: [{ path: 'x', content: 'PRIVATE_SOURCE' }], fileContext: { status: 'limited' } }], runtimeLogs: [] })
})
assert.deepEqual(plain(steps).map(step => [step.kind, step.status]), [['analysis', 'ok'], ['files', 'missing'], ['files', 'partial'], ['logs', 'empty'], ['analysis', 'ok']])
assert.equal(JSON.stringify(steps).includes('PRIVATE_SOURCE'), false)
})
test('tool stages distinguish missing files, partial reads and counts without exposing raw output', async () => {
const steps = []
const tools = [
{ id: 'missing', name: 'files.read', moduleName: 'Camera', args: { path: 'missing.yaml' } },
{ id: 'partial', name: 'files.read', moduleName: 'Camera', args: { path: 'large.py' } },
{ id: 'list', name: 'files.list', moduleName: 'Camera' },
{ id: 'search', name: 'files.search', moduleName: 'Camera', args: { query: 'fps' } }
]
let calls = 0
await context.resolveAgentContext({
signal: new AbortController().signal, initialContext: { modules: [], runtimeLogs: [] }, onStep: step => steps.push(step),
requestPlan: async () => response(++calls === 1 ? tools : []),
executeTool: async tool => result({ data: JSON.stringify(tool.args?.path === 'missing.yaml' ? { exists: false } : tool.args?.path === 'large.py' ? { complete: false } : tool.name === 'files.list' ? { paths: ['a', 'b'] } : { matches: [] }) })
})
assert.deepEqual(plain(steps.filter(step => step.kind === 'tool')).map(step => [step.status, step.count]), [['missing', undefined], ['partial', undefined], ['ok', 2], ['ok', 0]])
})