Files
lms-fe/scripts/test-agent-response-reveal.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

251 lines
14 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')
const { renderToStaticMarkup } = require('react-dom/server')
function load(file, overrides = {}, globals = {}) {
const exports = {}
const source = fs.readFileSync(require.resolve(file), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
const reveal = load('../src/renderer/src/components/agentResponseReveal.ts')
const { createAgentRevealDocument, agentRevealFrame, AGENT_REVEAL_INTERVAL } = reveal
const paragraph = 'MK-mini는 바퀴가 4개라도 전륜 조향 모델을 사용합니다.'
const content = `${paragraph}\n\n1. 물리적 바퀴 수와 운동학 모델은 다릅니다.\n - 앞바퀴로 조향합니다.\n2. 제어 설정을 확인합니다.\n\n마지막 설명입니다.`
test('reveals paragraphs and complete list items at fixed intervals without splitting Korean sentences', () => {
const document = createAgentRevealDocument(content)
const first = agentRevealFrame(document, 1000, 1000)
assert.equal(first.content, paragraph)
assert.equal(first.complete, false)
const second = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL)
assert.match(second.content, /1\. 물리적/)
assert.match(second.content, /앞바퀴로 조향합니다\./)
assert.doesNotMatch(second.content, /2\. 제어/)
const third = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL * 2)
assert.match(third.content, /2\. 제어 설정을 확인합니다\./)
assert.doesNotMatch(third.content, /마지막 설명/)
})
test('keeps fenced code and GFM tables intact', () => {
const text = '설명입니다.\n\n```python\nprint("시작")\nprint("종료")\n```\n\n| 항목 | 값 |\n| --- | --- |\n| 조향 | 전륜 |'
const document = createAgentRevealDocument(text)
assert.equal(document.boundaries.length, 3)
const code = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL).content
assert.equal((code.match(/```/g) ?? []).length, 2)
assert.match(code, /print\("종료"\)/)
assert.doesNotMatch(code, /\| 항목/)
assert.equal(agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL * 2).content, text)
})
test('reference link definitions are available before their position in the answer is revealed', () => {
const document = createAgentRevealDocument('[설명][guide]\n\n다음 문단\n\n[guide]: https://example.com/guide')
const first = agentRevealFrame(document, 0, 0)
assert.match(first.content, /\[guide\]: https:\/\/example.com\/guide/)
assert.doesNotMatch(first.content, /다음 문단/)
})
test('history, elapsed answers and reduced-motion users see the complete response immediately', () => {
const document = createAgentRevealDocument(content)
for (const frame of [agentRevealFrame(document, undefined, 0), agentRevealFrame(document, 0, 60_000), agentRevealFrame(document, 0, 0, true)]) {
assert.equal(frame.content, content)
assert.equal(frame.complete, true)
assert.equal(frame.fadeFrom, undefined)
}
})
test('long answers are grouped into at most twenty steps and finish within five seconds', () => {
const text = Array.from({ length: 100 }, (_, index) => `문단 ${index}입니다.`).join('\n\n')
const document = createAgentRevealDocument(text)
assert.ok(document.boundaries.length <= 20)
assert.ok(agentRevealFrame(document, 0, 0).duration < 5000)
assert.equal(agentRevealFrame(document, 0, 5000).content, text)
})
test('only the newly appended paragraph gets the fade class', () => {
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
})
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
const html = renderToStaticMarkup(React.createElement(MarkdownMessage, { content: frame.content, fadeFrom: frame.fadeFrom, dark: true }))
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
assert.match(html, /첫 번째 문단/)
assert.match(html, /두 번째 문단/)
assert.doesNotMatch(html, /세 번째 문단/)
})
function renderResponse(message, now, reducedMotion = false, extra = {}) {
const MarkdownMessage = ({ content }) => React.createElement('p', null, content)
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage: MarkdownMessage }, './agentResponseReveal': reveal,
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: reducedMotion }) } })
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
}
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
assert.ok(html.includes('A-Copilot'))
assert.ok(!html.includes('A_COPILOT'))
assert.ok(!html.includes('A-Copilot · A-Copilot'))
assert.equal((html.match(/>A-Copilot<\/p>/g) ?? []).length, 1)
assert.ok(renderResponse({ content: '결과', provider: 'QWEN', model: 'qwen3.8-max' }, 0).includes('QWEN · qwen3.8-max'))
})
test('approval controls wait for the explanation; restored history does not replay', () => {
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '조향 설명', explanation: content }, provider: 'QWEN', model: 'test' }
const first = renderResponse(message, 1000)
assert.match(first, /조향 설명/)
assert.match(first, /aria-busy="true"/)
assert.doesNotMatch(first, /승인|QWEN/)
for (const html of [renderResponse(message, 10_000), renderResponse({ ...message, revealStartedAt: undefined }, 1000), renderResponse(message, 1000, true)]) {
assert.match(html, /aria-busy="false"/)
assert.match(html, /마지막 설명/)
assert.match(html, /승인/)
assert.match(html, /QWEN/)
}
})
test('reveal timing is not included in conversation persistence API payloads', () => {
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let expression
function visit(node) {
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('messages: batch')) expression = node.getText(source)
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(expression)
const batch = [{ id: '1', role: 'assistant', content: '전체 답변', revealStartedAt: 1000 }]
const payload = JSON.parse(vm.runInNewContext(expression, { batch, courseId: 'course', weekNumber: 1 }))
assert.equal(Object.hasOwn(payload.messages[0], 'revealStartedAt'), false)
assert.equal(payload.messages[0].content, '전체 답변')
})
test('animation honors reduced motion in CSS', () => {
const css = fs.readFileSync(require.resolve('../src/renderer/src/index.css'), 'utf8')
assert.match(css, /@keyframes agent-response-fade/)
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*?\.agent-response-fade\s*\{\s*animation: none/)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
test('agent answers use the shared collapse widget after reveal, without hiding action controls', () => {
const MarkdownMessage = () => null
const ExpandableMarkdownMessage = () => null
let now = 1000
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage }, './agentResponseReveal': reveal,
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: false }) } })
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '요약', explanation: '상세 내용입니다.\n\n'.repeat(60) } }
const render = (extra = {}) => ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra })
assert.equal(elements(render(), node => node.type === ExpandableMarkdownMessage).length, 0)
now = 10_000
const fresh = render()
assert.equal(elements(fresh, node => node.type === ExpandableMarkdownMessage)[0].props.defaultCollapsed, false)
assert.equal(elements(fresh, node => node.type === 'button').length, 1)
const history = render({ message: { ...message, revealStartedAt: undefined }, collapseOnSignal: true, collapseSignal: 4 })
const widget = elements(history, node => node.type === ExpandableMarkdownMessage)[0]
assert.equal(widget.props.defaultCollapsed, true)
assert.equal(widget.props.collapseOnSignal, true)
assert.equal(widget.props.collapseSignal, 4)
assert.equal(elements(history, node => node.type === 'button').length, 1)
})
for (const dark of [false, true]) {
test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
let expanded
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
react: { ...React, useEffect() {}, useState(initial) {
if (expanded === undefined) expanded = initial
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
} }
})
const content = '파일과 파라미터에 대한 상세 설명입니다.\n'.repeat(50)
const render = () => ExpandableMarkdownMessage({ content, dark, defaultCollapsed: true })
let tree = render()
let button = elements(tree, node => node.type === 'button')[0]
assert.equal(button.props['aria-label'], '자세히 보기')
assert.ok(elements(tree, node => node.type === MarkdownMessage)[0].props.content.length < content.length)
button.props.onClick()
tree = render()
button = elements(tree, node => node.type === 'button')[0]
assert.equal(button.props['aria-label'], '접기')
assert.equal(elements(tree, node => node.type === MarkdownMessage)[0].props.content, content)
button.props.onClick()
assert.equal(elements(render(), node => node.type === 'button')[0].props['aria-label'], '자세히 보기')
})
}
test('short answers do not show an unnecessary collapse button', () => {
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
})
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
assert.equal(elements(tree, node => node.type === 'button').length, 0)
})
for (const dark of [false, true]) {
test(`model label and copy action share the last row after the operation controls (${dark ? 'dark' : 'light'})`, () => {
const html = renderResponse({ content: '전체 답변', provider: 'QWEN', model: 'qwen-model-name'.repeat(8) }, 1000, false, {
dark,
footerAction: React.createElement('button', { title: '복사', className: 'copy-action' }, 'Copy')
})
assert.match(html, /class="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2"><p[^>]*class="min-w-0 flex-1 truncate[^>]*>QWEN[^<]*<\/p><button title="복사"/)
assert.ok(html.indexOf('승인') < html.indexOf('QWEN'))
})
}
test('copy footer remains available when the message has no model metadata', () => {
const html = renderResponse({ content: '오류 안내' }, 1000, false, {
footerAction: React.createElement('button', { title: '복사' }, 'Copy')
})
assert.match(html, /min-h-6/)
assert.match(html, /title="복사"/)
})
test('reply copy is an inline fixed-size hover button and still copies the full response', async () => {
const source = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declaration = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'MessageCopyButton')
let copied = false
let clipboardText
const { MessageCopyButton } = compileCopy(declaration.getText(source))
function compileCopy(text) {
const code = ts.transpileModule(`export ${text}`, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
const exports = {}
vm.runInNewContext(code, {
exports, require,
useState: () => [copied, value => { copied = value }], t: key => key,
Check: () => null, Copy: () => null,
navigator: { clipboard: { writeText: async text => { clipboardText = text } } },
window: { setTimeout() {} }
})
return exports
}
const content = '# 요약\n\n전체 답변입니다.'
const button = MessageCopyButton({ content, dark: true })
assert.match(button.props.className, /opacity-0.*group-hover:opacity-100/)
assert.match(button.props.className, /shrink-0 cursor-pointer/)
assert.doesNotMatch(button.props.className, /absolute|bottom-|right-/)
button.props.onClick()
await Promise.resolve()
assert.equal(clipboardText, content)
assert.equal(MessageCopyButton({ content, dark: true }).props.title, '복사됨')
})