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