feat(chat): add agent attachments and shared conversation confirmation
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
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 output = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, { exports: output, DOMException })
|
||||
const { resolveAgentContext } = output
|
||||
const plain = value => JSON.parse(JSON.stringify(value))
|
||||
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
|
||||
const response = (requestedModules = [], requestedLogs = []) => ({
|
||||
provider: 'QWEN', model: 'test', usage: { totalTokens: 10 },
|
||||
plan: { summary: 'result', explanation: 'analysis', requestedModules, requestedLogs, actionPlan: [], commands: [], changes: [], createModule: null }
|
||||
})
|
||||
const options = overrides => ({
|
||||
initialContext: { modules: [moduleInfo('MKMini'), moduleInfo('Control')], runtimeLogs: [] },
|
||||
signal: new AbortController().signal,
|
||||
...overrides
|
||||
})
|
||||
|
||||
test('continues MKMini -> Control -> final analysis and retains earlier source files', async () => {
|
||||
const inputs = []
|
||||
const loads = []
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
inputs.push(plain(context))
|
||||
return [response(['MKMini']), response(['Control']), response()][inputs.length - 1]
|
||||
},
|
||||
loadRequestedContext: async request => {
|
||||
loads.push(plain(request))
|
||||
return { modules: ['MKMini', 'Control'].map(name => moduleInfo(name, request.modules.includes(name) ? [{ path: `${name}.py`, content: `${name} source` }] : [])), runtimeLogs: [] }
|
||||
}
|
||||
}))
|
||||
assert.equal(inputs.length, 3)
|
||||
assert.deepEqual(loads, [{ modules: ['MKMini'], logs: [] }, { modules: ['Control'], logs: [] }])
|
||||
assert.equal(inputs[2].modules[0].files[0].content, 'MKMini source')
|
||||
assert.equal(inputs[2].modules[1].files[0].content, 'Control source')
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
assert.equal(result.usage.totalTokens, 30)
|
||||
})
|
||||
|
||||
test('keeps logs while loading a later source module', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
calls += 1
|
||||
if (calls === 3) assert.equal(context.runtimeLogs[0].content, 'error log')
|
||||
return [response([], ['Control']), response(['Control']), response()][calls - 1]
|
||||
},
|
||||
loadRequestedContext: async request => ({
|
||||
modules: [moduleInfo('Control', request.modules.length ? [{ path: 'node.py', content: 'code' }] : [])],
|
||||
runtimeLogs: request.logs.length ? [{ moduleName: 'Control', state: 'running', content: 'error log' }] : []
|
||||
})
|
||||
}))
|
||||
assert.equal(calls, 3)
|
||||
})
|
||||
|
||||
test('rejects repeated requests instead of returning a stalled plan', async () => {
|
||||
let loads = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
requestPlan: async () => response(['Control']),
|
||||
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
|
||||
})), /반복 요청/)
|
||||
assert.equal(loads, 1)
|
||||
})
|
||||
|
||||
test('requests only new resources when the model also repeats an earlier module', async () => {
|
||||
let count = 0
|
||||
const loads = []
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async () => [response(['MKMini']), response(['MKMini', 'Control']), response()][count++],
|
||||
loadRequestedContext: async request => { loads.push(plain(request)); return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
|
||||
}))
|
||||
assert.deepEqual(loads[1], { modules: ['Control'], logs: [] })
|
||||
})
|
||||
|
||||
test('does not start another API call after cancellation during context loading', async () => {
|
||||
const controller = new AbortController()
|
||||
let calls = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
signal: controller.signal,
|
||||
requestPlan: async () => { calls += 1; return response(['Control']) },
|
||||
loadRequestedContext: async () => { controller.abort(); return { modules: [], runtimeLogs: [] } }
|
||||
})), { name: 'AbortError' })
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test('bounds the number of distinct automatic context requests', async () => {
|
||||
let calls = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
requestPlan: async () => response([`Module${calls++}`]),
|
||||
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] })
|
||||
})), /5회/)
|
||||
assert.equal(calls, 6)
|
||||
})
|
||||
|
||||
test('passes file-read failures to the model for a concrete final explanation', async () => {
|
||||
let count = 0
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
if (count++ === 0) return response(['Control'])
|
||||
assert.equal(context.modules.find(module => module.name === 'Control').fileContext.status, 'error')
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [{ ...moduleInfo('Control'), fileContext: { status: 'error', message: 'permission denied' } }], runtimeLogs: [] })
|
||||
}))
|
||||
})
|
||||
|
||||
test('does not silently truncate accumulated source context beyond API limits', async () => {
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
requestPlan: async () => response(['Control']),
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }])], runtimeLogs: [] })
|
||||
})), /조회 한도/)
|
||||
})
|
||||
|
||||
test('does not display a context-only response when no loader is available', async () => {
|
||||
await assert.rejects(resolveAgentContext(options({ requestPlan: async () => response(['Control']) })), /읽을 수 없는/)
|
||||
})
|
||||
|
||||
test('keeps a just-started module running when a stale collector supplies source files', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
initialContext: { modules: [{ ...moduleInfo('Control'), state: 'running' }], runtimeLogs: [] },
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['Control'])
|
||||
assert.equal(context.modules[0].state, 'running')
|
||||
assert.equal(context.modules[0].files[0].content, 'code')
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'node.py', content: 'code' }])], runtimeLogs: [] })
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
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 source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
let payloadExpression
|
||||
function visit(node) {
|
||||
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('options.attachments')) {
|
||||
payloadExpression = node.getText(source)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
|
||||
|
||||
function serialize(attachments) {
|
||||
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
||||
courseId: 'course-1', weekNumber: 3, request: '설정 위치 알려줘', options: { attachments },
|
||||
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
|
||||
}))
|
||||
}
|
||||
|
||||
for (const attachments of [undefined, []]) {
|
||||
test(`omits attachments when ${attachments ? 'empty' : 'undefined'}`, () => {
|
||||
const body = serialize(attachments)
|
||||
assert.equal(Object.hasOwn(body, 'attachments'), false)
|
||||
assert.equal(body.request, '설정 위치 알려줘')
|
||||
assert.equal(body.modules[0].name, 'Control')
|
||||
assert.equal(body.week, 3)
|
||||
})
|
||||
}
|
||||
|
||||
test('preserves actual attachment contents rather than silently dropping them', () => {
|
||||
const attachments = [{ name: 'config.yaml', content: 'steering: ackermann' }]
|
||||
assert.deepEqual(serialize(attachments).attachments, attachments)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
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 output = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, { exports: output, TextDecoder })
|
||||
const { readChatAttachments } = output
|
||||
const file = (name, content) => new File([content], name)
|
||||
|
||||
test('reads source/config/log files and preserves Korean UTF-8 text', async () => {
|
||||
const files = await readChatAttachments([file('control.py', '# 조향 설정'), file('config.yaml', 'robot: mkmini'), file('runtime.log', 'started')], [])
|
||||
assert.equal(files[0].content, '# 조향 설정')
|
||||
assert.equal(files[2].name, 'runtime.log')
|
||||
})
|
||||
test('rejects unsupported image/PDF files', async () => {
|
||||
await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /이미지와 PDF/)
|
||||
await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /이미지와 PDF/)
|
||||
})
|
||||
test('rejects more than five attachments including existing ones', async () => {
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a')], Array(5).fill({ name: 'b.txt', content: '' })), /최대 5개/)
|
||||
})
|
||||
test('rejects binary and non-UTF8 content even under a text extension', async () => {
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a\0b')], []), /바이너리/)
|
||||
await assert.rejects(readChatAttachments([file('a.txt', new Uint8Array([255, 254]))], []), /UTF-8/)
|
||||
})
|
||||
test('rejects oversized files instead of silently truncating', async () => {
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(100_001))], []), /100KB/)
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_001))], []), /5만 자/)
|
||||
})
|
||||
test('rejects aggregate attachment context above 100000 characters', async () => {
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_000)), file('b.txt', 'a'.repeat(50_000))], []), /10만 자/)
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
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 compile(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 i18n = { t: key => key, useLocaleRender() {} }
|
||||
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
|
||||
const QaInputBox = () => null
|
||||
|
||||
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 setup(overrides = {}) {
|
||||
const state = []
|
||||
let index = 0
|
||||
let cleared = 0
|
||||
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
|
||||
'../../i18n': i18n,
|
||||
'../../components/AppStatusModals': { ConfirmStatusModal },
|
||||
'../../components/ChatWidgets': { QaInputBox },
|
||||
'react-dom': { createPortal: child => child },
|
||||
react: {
|
||||
...React,
|
||||
useEffect() {},
|
||||
useState(initial) {
|
||||
const slot = index++
|
||||
if (!(slot in state)) state[slot] = initial
|
||||
return [state[slot], value => { state[slot] = value }]
|
||||
}
|
||||
}
|
||||
}, { document: { body: {} } }).default
|
||||
const props = {
|
||||
dark: true, workspaceActive: true, qaMode: 'ai', visibleAiMessages: [{ id: '1', content: 'hello' }],
|
||||
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
|
||||
onClearConversation() { cleared++ }, ...overrides
|
||||
}
|
||||
return {
|
||||
render() { index = 0; return panel(props) },
|
||||
cleared: () => cleared
|
||||
}
|
||||
}
|
||||
|
||||
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
||||
test(`${qaMode} history is deleted only after shared modal confirmation`, () => {
|
||||
const harness = setup({ qaMode, agentMessages: [{ id: 'a', content: 'answer' }], visibleTeacherMessages: [{ id: 't', content: 'answer' }] })
|
||||
const input = tree => elements(tree, node => node.type === QaInputBox)[0]
|
||||
const modal = tree => elements(tree, node => node.type === ConfirmStatusModal)[0]
|
||||
assert.equal(modal(harness.render()), undefined)
|
||||
input(harness.render()).props.onClearConversation()
|
||||
assert.equal(harness.cleared(), 0)
|
||||
modal(harness.render()).props.onCancel()
|
||||
assert.equal(modal(harness.render()), undefined)
|
||||
assert.equal(harness.cleared(), 0)
|
||||
input(harness.render()).props.onClearConversation()
|
||||
modal(harness.render()).props.onConfirm()
|
||||
assert.equal(harness.cleared(), 1)
|
||||
assert.equal(modal(harness.render()), undefined)
|
||||
})
|
||||
}
|
||||
|
||||
test('context menu deletion also requests confirmation', () => {
|
||||
const harness = setup()
|
||||
const root = harness.render()
|
||||
const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
|
||||
area.props.onContextMenu({ target: { closest: () => null }, preventDefault() {}, clientX: 10, clientY: 10 })
|
||||
const clear = elements(harness.render(), node => node.type === 'button' && node.props.children === '대화내역 삭제')[0]
|
||||
clear.props.onClick()
|
||||
assert.equal(harness.cleared(), 0)
|
||||
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
|
||||
})
|
||||
|
||||
for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) {
|
||||
test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => {
|
||||
const harness = setup(props)
|
||||
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
|
||||
assert.equal(input.props.clearDisabled, true)
|
||||
input.props.onClearConversation()
|
||||
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 0)
|
||||
assert.equal(harness.cleared(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
test(`shared confirmation renders cancel left and delete right (${dark ? 'dark' : 'light'})`, () => {
|
||||
const harness = setup({ dark })
|
||||
elements(harness.render(), node => node.type === QaInputBox)[0].props.onClearConversation()
|
||||
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
|
||||
assert.equal(modal.props.dark, dark)
|
||||
assert.match(modal.props.overlayClassName, /fixed.*z-\[180\]/)
|
||||
const html = renderToStaticMarkup(modal)
|
||||
assert.match(html, /취소<\/button><button[^>]*>삭제<\/button>/)
|
||||
assert.match(html, /bg-\[#d14b4b\]/)
|
||||
})
|
||||
}
|
||||
|
||||
test('input no longer opens a native confirmation dialog', () => {
|
||||
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||
assert.doesNotMatch(source, /window\.confirm/)
|
||||
assert.match(source, /onClick=\{onClearConversation\}/)
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
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')
|
||||
|
||||
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/AppStatusModals.tsx'), 'utf8')
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText
|
||||
|
||||
function loadModals() {
|
||||
const exports = {}
|
||||
vm.runInNewContext(compiled, {
|
||||
exports,
|
||||
require: name => name === '../i18n' ? { t: key => key, useLocaleRender() {} } : require(name)
|
||||
})
|
||||
return exports
|
||||
}
|
||||
|
||||
function buttons(element) {
|
||||
if (!React.isValidElement(element)) return []
|
||||
if (element.type === 'button') return [element]
|
||||
return React.Children.toArray(element.props.children).flatMap(buttons)
|
||||
}
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
test(`extension action stays on the right in both dialogs (${dark ? 'dark' : 'light'})`, () => {
|
||||
const { StatusNoticeModal, ControlExtensionConfirmModal } = loadModals()
|
||||
let confirmed = 0
|
||||
let extended = 0
|
||||
const notice = StatusNoticeModal({
|
||||
overlayClassName: 'overlay', dark, tone: 'clock', title: '제어 시간 안내', message: '제어 시간이 곧 종료됩니다.',
|
||||
actionLabel: '연장', onAction: () => { extended++ }, onConfirm: () => { confirmed++ }
|
||||
})
|
||||
const noticeButtons = buttons(notice)
|
||||
assert.deepEqual(noticeButtons.map(button => button.props.children), ['확인', '연장'])
|
||||
assert.match(noticeButtons[1].props.className, /bg-\[#6f4df6\]/)
|
||||
assert.doesNotMatch(noticeButtons[0].props.className, /bg-\[#6f4df6\]/)
|
||||
noticeButtons[0].props.onClick()
|
||||
noticeButtons[1].props.onClick()
|
||||
assert.equal(confirmed, 1)
|
||||
assert.equal(extended, 1)
|
||||
|
||||
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark, platformName: 'MK-mini', onCancel() {}, onConfirm() {} })
|
||||
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장'])
|
||||
assert.match(buttons(confirmation)[1].props.className, /bg-\[#6f4df6\]/)
|
||||
assert.match(renderToStaticMarkup(confirmation), /취소<\/button><button[^>]*>연장<\/button>/)
|
||||
})
|
||||
}
|
||||
|
||||
test('notices without an extension action retain their single primary confirmation button', () => {
|
||||
const { StatusNoticeModal } = loadModals()
|
||||
const notice = StatusNoticeModal({ overlayClassName: 'overlay', dark: true, tone: 'warning', title: '안내', message: '메시지', onConfirm() {} })
|
||||
assert.equal(buttons(notice).length, 1)
|
||||
assert.equal(buttons(notice)[0].props.children, '확인')
|
||||
assert.match(buttons(notice)[0].props.className, /bg-\[#6f4df6\]/)
|
||||
})
|
||||
|
||||
test('the extension confirmation keeps the right button disabled while submitting', () => {
|
||||
const { ControlExtensionConfirmModal } = loadModals()
|
||||
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark: true, platformName: 'MK-mini', submitting: true, onCancel() {}, onConfirm() {} })
|
||||
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장 중'])
|
||||
assert.ok(buttons(confirmation).every(button => button.props.disabled))
|
||||
})
|
||||
Reference in New Issue
Block a user