Files
lms-fe/scripts/test-agent-plan-cancel.cjs
T
chyoo 8b501266ff 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.
2026-10-19 17:06:18 +09:00

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)
})
}