feat(chat): add agent attachments and shared conversation confirmation

This commit is contained in:
2026-10-16 11:14:01 +09:00
parent ef260c8fb4
commit 4bdc5af620
13 changed files with 715 additions and 88 deletions
+135
View File
@@ -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: [] })
}))
})
+38
View File
@@ -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)
})
+35
View File
@@ -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만 자/)
})
+115
View File
@@ -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\}/)
})
+67
View File
@@ -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))
})