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:
@@ -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]])
|
||||
})
|
||||
Reference in New Issue
Block a user