8b501266ff
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.
147 lines
7.4 KiB
JavaScript
147 lines
7.4 KiB
JavaScript
const { test } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const vm = require('node:vm')
|
|
const ts = require('typescript')
|
|
const React = require('react')
|
|
|
|
function compile(source, globals) {
|
|
const exports = {}
|
|
vm.runInNewContext(ts.transpileModule(source, {
|
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
|
}).outputText, { exports, require, ...globals })
|
|
return exports
|
|
}
|
|
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
|
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['applyPlan', 'cancelPlan'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
|
|
const proposal = (extra = {}) => ({
|
|
id: 'proposal', role: 'assistant', content: '로그 추가', applyState: 'ready',
|
|
plan: { summary: '로그 추가', explanation: '시작 로그', requestedModules: [], requestedLogs: [], actionPlan: [], createModule: null,
|
|
changes: [{ moduleName: 'Control', path: 'node.py', content: 'new', reason: 'log' }],
|
|
commands: [{ id: 'build', moduleName: 'Control', command: 'bash build.sh', purpose: 'build' }] },
|
|
commandExecutions: { build: { status: 'pending' } }, ...extra
|
|
})
|
|
function harness(message) {
|
|
const session = {}
|
|
const messageStateRef = { current: { session, messages: [message] } }
|
|
const applyingRef = { current: false }
|
|
const abortControllerRef = { current: null }
|
|
const saved = []
|
|
const api = compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), {
|
|
session, messageStateRef, applyingRef, abortControllerRef,
|
|
updateMessages(update) {
|
|
const next = update(messageStateRef.current.messages)
|
|
messageStateRef.current.messages = next
|
|
saved.push(JSON.parse(JSON.stringify(next)))
|
|
}
|
|
})
|
|
return { ...api, messageStateRef, applyingRef, abortControllerRef, saved, current: () => messageStateRef.current.messages[0] }
|
|
}
|
|
|
|
test('cancel marks the proposal and unfinished commands without applying files', async () => {
|
|
const h = harness(proposal({ applyError: 'old error' }))
|
|
assert.equal(h.cancelPlan('proposal'), true)
|
|
assert.equal(h.current().applyState, 'cancelled')
|
|
assert.equal(h.current().applyError, undefined)
|
|
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
|
assert.equal(h.saved.at(-1)[0].applyState, 'cancelled')
|
|
let applied = false
|
|
await h.applyPlan('proposal', async () => { applied = true })
|
|
assert.equal(applied, false)
|
|
assert.equal(h.cancelPlan('proposal'), false)
|
|
})
|
|
|
|
test('cancel preserves existing successful command results and rejects failed commands', () => {
|
|
const message = proposal()
|
|
message.plan.commands.push({ id: 'inspect', moduleName: 'Control', command: 'inspect', purpose: 'inspect' })
|
|
message.commandExecutions = { build: { status: 'failed', output: 'build failed' }, inspect: { status: 'completed', output: 'done' } }
|
|
const h = harness(message)
|
|
h.cancelPlan('proposal')
|
|
assert.equal(h.current().commandExecutions.inspect.status, 'completed')
|
|
assert.equal(h.current().commandExecutions.inspect.output, 'done')
|
|
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
|
assert.equal(h.current().commandExecutions.build.output, 'build failed')
|
|
})
|
|
|
|
for (const applyState of ['applying', 'applied', 'cancelled']) {
|
|
test(`cannot cancel a proposal in ${applyState} state`, () => {
|
|
const h = harness(proposal({ applyState }))
|
|
assert.equal(h.cancelPlan('proposal'), false)
|
|
assert.equal(h.saved.length, 0)
|
|
})
|
|
}
|
|
|
|
test('cannot cancel during a running command, another apply, pending request, or changed conversation', () => {
|
|
for (const setup of [
|
|
h => { h.current().commandExecutions.build.status = 'running' },
|
|
h => { h.applyingRef.current = true },
|
|
h => { h.abortControllerRef.current = {} },
|
|
h => { h.messageStateRef.current.session = {} }
|
|
]) {
|
|
const h = harness(proposal())
|
|
setup(h)
|
|
assert.equal(h.cancelPlan('proposal'), false)
|
|
assert.equal(h.saved.length, 0)
|
|
}
|
|
})
|
|
|
|
test('an apply already started cannot be cancelled and completes normally', async () => {
|
|
const h = harness(proposal())
|
|
let finish
|
|
const applying = h.applyPlan('proposal', () => new Promise(resolve => { finish = resolve }))
|
|
assert.equal(h.current().applyState, 'applying')
|
|
assert.equal(h.cancelPlan('proposal'), false)
|
|
finish()
|
|
await applying
|
|
assert.equal(h.current().applyState, 'applied')
|
|
})
|
|
|
|
test('new module proposals can be cancelled without creating anything', () => {
|
|
const message = proposal()
|
|
message.plan.changes = []
|
|
message.plan.createModule = { name: 'Example' }
|
|
const h = harness(message)
|
|
assert.equal(h.cancelPlan('proposal'), true)
|
|
assert.equal(h.current().applyState, 'cancelled')
|
|
})
|
|
|
|
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 text(root) {
|
|
if (typeof root === 'string') return root
|
|
return React.isValidElement(root) ? React.Children.toArray(root.props.children).map(text).join('') : ''
|
|
}
|
|
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
const renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, {
|
|
...require('lucide-react'), t: key => key, useLocaleRender() {},
|
|
ProgressiveChatResponse: () => null, MessageCopyButton: () => null,
|
|
AgentWorkDuration: () => null,
|
|
agentMessageCopyText: message => message.content
|
|
}).AgentMessages
|
|
|
|
for (const dark of [true, false]) {
|
|
test(`cancel is left of apply, disabled during work, and absent after cancellation (${dark ? 'dark' : 'light'})`, () => {
|
|
let cancelled
|
|
const props = { dark, enabled: true, statusLoading: false, pending: false,
|
|
messages: [proposal()], onApply() {}, onCancel: id => { cancelled = id }, onOpenChange() {}, onRunCommand() {}, onRejectCommand() {} }
|
|
let tree = renderAgent(props)
|
|
let buttons = elements(tree, node => node.type === 'button')
|
|
const cancel = buttons.find(button => text(button) === '취소')
|
|
const apply = buttons.find(button => text(button) === '변경 적용')
|
|
assert.ok(buttons.indexOf(cancel) < buttons.indexOf(apply))
|
|
const cancelLabel = elements(cancel, node => node.type === 'span' && text(node) === '취소')[0]
|
|
assert.match(cancelLabel.props.className, /top-px leading-none/)
|
|
cancel.props.onClick()
|
|
assert.equal(cancelled, 'proposal')
|
|
const busyCancel = elements(renderAgent({ ...props, busy: true }), node => node.type === 'button' && text(node) === '취소')[0]
|
|
assert.equal(busyCancel.props.disabled, true)
|
|
tree = renderAgent({ ...props, messages: [proposal({ applyState: 'cancelled' })] })
|
|
buttons = elements(tree, node => node.type === 'button')
|
|
assert.equal(buttons.some(button => ['취소', '승인', '다시 적용', '변경 적용'].includes(text(button))), false)
|
|
assert.equal(buttons.find(button => text(button) === '취소됨').props.disabled, true)
|
|
})
|
|
}
|