import assert from 'node:assert/strict' import { renderToStaticMarkup } from 'react-dom/server' import { ComposerControls } from '../src/renderer/src/workbuddy/composer/ComposerControls' import { encodePcmWav, downsamplePcm, VOICE_MAX_DURATION_MS } from '../src/renderer/src/workbuddy/composer/VoiceInputControl' import { buildComposerSendPayload, MAX_ATTACHMENT_BYTES, oversizedAttachmentNames, parseResourcePath, readReferenceUris } from '../src/renderer/src/workbuddy/composer/ComposerControls' import { MessageActionToolbar } from '../src/renderer/src/workbuddy/message-actions/MessageActionToolbar' import { UserMessageToolbar } from '../src/renderer/src/workbuddy/message-actions/UserMessageToolbar' import { UserMessageEditor } from '../src/renderer/src/workbuddy/conversation/UserMessageEditor' import { buildCopyMessagePayload, buildUserMessageClipboard, getSortedRequestMessages, projectSafeMessage, safeStringify, USER_CONTENT_BLOCKS_MIME } from '../src/renderer/src/workbuddy/message-actions/project-safe-message' import type { ChatMessage } from '../src/shared/types' const protectedMessage = { id: 'assistant-sensitive', messageType: 'assistant', content: [ { type: 'text', text: 'before secret after', _meta: { sensitiveProtection: { version: 2, items: [{ start: 7, end: 13 }] } } }, { type: 'tool-call', tool: { id: 'secret-tool', name: 'readFile', status: 'ok', args: { path: '/private' }, metaData: { sensitiveProtection: { version: 1, items: [{ ciphertext: 'ciphertext-1' }] } }, result: { private: true } } } ] } const originalSerialized = JSON.stringify(protectedMessage) const projected = projectSafeMessage(protectedMessage) assert.equal(JSON.stringify(protectedMessage), originalSerialized, 'projection must preserve the caller object') const projectedText = (projected.content?.[0] as { text: string }).text assert.equal(projectedText.startsWith('before ') && projectedText.endsWith(' after'), true) assert.equal(projectedText.includes('secret'), false, 'copy projection must redact protected text') const unchangedUser = { messageType: 'user', content: [{ type: 'text', text: 'user secret', _meta: { sensitiveProtection: { version: 2, items: [{ start: 5, end: 11 }] } } }] } assert.equal(projectSafeMessage(unchangedUser), unchangedUser, 'assistant output hooks leave user messages unchanged') assert.equal((projected.content?.[1] as { tool: { args: { sensitiveContent: string } } }).tool.args.sensitiveContent, 'ciphertext-1') const quickQa = projectSafeMessage({ messageType: 'assistant', extra: { quickQa: true }, content: [ { type: 'text', text: 'answer' }, { type: 'tool-call', tool: { id: 'hidden', name: 'search', status: 'ok' } } ] }) assert.equal(quickQa.content?.length, 1, 'Quick QA copy projection omits tool-call blocks') const legacy: ChatMessage = { id: 'legacy-assistant', role: 'assistant', content: 'legacy text', tools: [{ id: 'legacy-tool', name: 'readFile', args: { path: '/tmp/a' }, argsRaw: '{}', output: 'full output', status: 'ok' }], createdAt: 1 } const userMessage: ChatMessage = { id: 'user-copy', role: 'user', content: 'plain user text', createdAt: 1 } const userClipboard = buildUserMessageClipboard({ ...userMessage, content: '&\u200B' }) assert.equal(userClipboard.plainText, '&', 'user text/plain omits the source zero-width marker') assert.equal(userClipboard.html, '<plain>&\u200B') assert.equal(userClipboard.customMimeType, USER_CONTENT_BLOCKS_MIME) assert.deepEqual(JSON.parse(userClipboard.customData), { version: '1.0', blocks: [{ type: 'text', text: '&\u200B' }] }) const richClipboard = buildUserMessageClipboard({ ...userMessage, content: 'see &\u200B', attachments: [ { id: 'image-1', name: 'preview.png', mimeType: 'image/png', size: 3, previewUrl: 'data:image/png;base64,AQID', path: 'C:\\work\\preview.png' }, { id: 'file-1', name: 'notes.pdf', mimeType: 'application/pdf', size: 4, path: 'C:\\work\\notes.pdf' } ], resources: ['C:\\work\\src.ts', 'C:\\work\\src.ts'] }) const richPayload = JSON.parse(richClipboard.customData) as { version: string blocks: Array<{ type: string; text?: string; data?: string; mimeType?: string; name?: string; uri?: string; _meta?: Record }> } assert.equal(richPayload.version, '1.0') assert.deepEqual(richPayload.blocks.map((block) => block.type), ['text', 'image', 'resource_link', 'resource_link', 'resource_link'], 'custom MIME should preserve the text, image, file, and both resource blocks') assert.equal(richClipboard.customMimeType, 'application/x-cb-chat-contentblocks') assert.equal(richClipboard.plainText, 'Context: C:\\work\\src.ts\n\nsee &preview.pngnotes.pdf', 'plain text should summarize context links once and append attachment phrases') assert.ok(richClipboard.html.includes('see <img>&\u200B'), 'HTML should escape text while retaining source zero-width markers') assert.equal((richClipboard.html.match(/data-contentblock=/g) ?? []).length, 2, 'phrase attachment blocks should be carried in the HTML data-contentblock spans') assert.ok(!richClipboard.html.includes('C:\\work\\src.ts'), 'context links are represented in plain text and custom MIME, not HTML') assert.equal(richPayload.blocks[1].data, 'AQID') assert.equal(richPayload.blocks[1].mimeType, 'image/png') assert.equal(richPayload.blocks[1]._meta?.filename, 'preview.png') assert.equal(richPayload.blocks[2].uri, 'C:\\work\\notes.pdf') assert.equal(richPayload.blocks[2]._meta?.filePath, 'C:\\work\\notes.pdf') assert.equal(richPayload.blocks[3]._meta?.displayAsContext, true) const payload = buildCopyMessagePayload('request-1', [legacy]) assert.equal(payload.requestId, 'request-1') assert.equal(payload.messageCount, 1) assert.equal(payload.toolCallCount, 1) assert.equal(payload.summary[0].contentBlockCount, 2, 'legacy content and tools both appear in fallback copy') assert.equal((payload.messages[0].content?.[1] as { tool: { result: string } }).tool.result, 'full output') assert.equal(JSON.parse(safeStringify(payload)).summary[0].toolCalls?.[0].id, 'legacy-tool') const circular: { self?: unknown } = {} circular.self = circular assert.equal(JSON.parse(safeStringify(circular)).self, '[Circular]') const requestPayload = buildCopyMessagePayload('request-2', [ { id: 'user', role: 'user', content: 'question', createdAt: 1 } as ChatMessage, { id: 'a1', messageType: 'assistant', content: [{ type: 'text', text: 'one' }] }, { id: 'a2', messageType: 'assistant', content: [{ type: 'text', text: 'two' }] } ]) assert.equal(requestPayload.messageCount, 2, 'request copy envelope includes assistant messages only') const requestOrder = getSortedRequestMessages([ { id: 'late', role: 'assistant', requestId: 'ordered', content: 'later', createdAt: 20 }, { id: 'other', role: 'assistant', requestId: 'different', content: 'other', createdAt: 5 }, { id: 'early', role: 'assistant', requestId: 'ordered', content: 'earlier', createdAt: 10 } ], 'ordered') assert.deepEqual(requestOrder.map((item) => item.id), ['early', 'late']) const samples = Float32Array.from([-1, -0.5, 0, 0.5, 1, 0]) assert.equal(downsamplePcm(samples, 32_000).length, 3) const wav = encodePcmWav(samples) assert.equal(new TextDecoder().decode(wav.slice(0, 4)), 'RIFF') assert.equal(new DataView(wav.buffer).getUint32(24, true), 16_000) assert.equal(new DataView(wav.buffer).getUint32(40, true), samples.length * 2) assert.equal(VOICE_MAX_DURATION_MS, 295_000) assert.deepEqual(oversizedAttachmentNames([ { name: 'limit.bin', size: MAX_ATTACHMENT_BYTES }, { name: 'over.bin', size: MAX_ATTACHMENT_BYTES + 1 } ]), ['over.bin']) assert.deepEqual(buildComposerSendPayload(' prompt ', [], ['C:/work/file.ts'], 'full-access'), { text: 'prompt', attachments: [], resources: ['C:/work/file.ts'], quotes: [], mode: 'full-access' }) const quotedPayload = buildComposerSendPayload('', [], [], 'full-access', [ { id: 'm1-text-0:table:table-0', kind: 'table', sourceId: 'm1-text-0:table:table-0', messageId: 'm1', markdown: '| a |\n| - |\n| 1 |', preview: '| a |' } ]) assert.deepEqual(quotedPayload.quotes.map((quote) => quote.sourceId), ['m1-text-0:table:table-0']) assert.equal(quotedPayload.text, '', '引用单独存在时正文可以为空') assert.equal(parseResourcePath('file:///C:/work/file.ts'), 'C:\\work\\file.ts') assert.equal(parseResourcePath('C:\\work\\file.ts'), 'C:\\work\\file.ts') assert.deepEqual(readReferenceUris({ getData: (type) => type === 'application/vnd.code.uri-list' ? 'file:///C:/a.ts\r\nfile:///C:/b.ts' : '' }), ['file:///C:/a.ts', 'file:///C:/b.ts']) assert.deepEqual(readReferenceUris({ getData: (type) => type === 'resourceurls' ? '["file:///C:/a.ts"]' : '' }), ['file:///C:/a.ts']) const assistant: ChatMessage = { id: 'toolbar-assistant', role: 'assistant', content: 'done', createdAt: 1, requestId: 'request-3' } const assistantMarkup = renderToStaticMarkup( undefined} />) assert.match(assistantMarkup, /复制/) assert.match(assistantMarkup, /重新生成/) assert.doesNotMatch(assistantMarkup, /编辑/) // 用户消息工具条已替换为原版 .cr-toolbar(时间 + 复制 + 恢复此版本 + 编辑,不含重新发送)。 const userMarkup = renderToStaticMarkup( undefined} onRevert={() => undefined} />) assert.match(userMarkup, /class="cr-toolbar"/) assert.match(userMarkup, /cr-toolbar__time/) assert.match(userMarkup, /data-action="copy"/) assert.match(userMarkup, /data-action="revert"/) assert.match(userMarkup, /data-action="edit"/) assert.match(userMarkup, /cr-icon-button__icon/) assert.doesNotMatch(userMarkup, /重新发送|重新生成|赞同|不赞同/) assert.doesNotMatch(userMarkup, /wb-message-action__label/, '工具栏只保留图标,不再渲染文字标签') const editorMarkup = renderToStaticMarkup( undefined} onCancel={() => undefined} />) assert.match(editorMarkup, /class="cr-user-message-editor"/) assert.match(editorMarkup, /cr-input-container-wrapper/) assert.match(editorMarkup, /cr-input-editor-host/) assert.match(editorMarkup, /data-slate-editor/) assert.match(editorMarkup, /cr-user-message-editor__hint-icon/) assert.match(editorMarkup, /编辑后将从此处重新开始对话,产物也将回退至此信息前版本/) assert.match(editorMarkup, /cr-user-message-editor__cancel/) assert.match(editorMarkup, /cr-user-message-editor__send/) assert.match(editorMarkup, />取消发送 undefined} onCancel={() => undefined} />) assert.match(emptyEditorMarkup, /消息内容不能为空/) assert.match(emptyEditorMarkup, /role="alert"/) assert.match(emptyEditorMarkup, /cr-user-message-editor__send" disabled/) const composerMarkup = renderToStaticMarkup( undefined} onSend={() => undefined} onModeChange={() => undefined} modelSlot={Model slot} workspaceFooter={Workspace slot} sending /> ) assert.match(composerMarkup, /语音转写服务不可用/, 'without an ASR callback, voice input reports unavailable') assert.match(composerMarkup, /默认权限/) assert.match(composerMarkup, /添加/) assert.match(composerMarkup, /Model slot/) assert.match(composerMarkup, /Workspace slot/) assert.equal(composerMarkup.indexOf('Model slot') < composerMarkup.indexOf('语音转写服务不可用'), true) assert.ok(composerMarkup.indexOf('cr-input-toolbar__left') < composerMarkup.indexOf('默认权限')) assert.ok(composerMarkup.indexOf('默认权限') < composerMarkup.indexOf('cr-input-toolbar__right'), 'permission control belongs in the left toolbar') assert.ok(composerMarkup.indexOf('cr-input-toolbar__right') < composerMarkup.indexOf('wb-composer__workspace-footer'), 'workspace footer remains outside the input toolbar') const activeRunMarkup = renderToStaticMarkup( undefined} onSend={() => undefined} onStop={() => undefined} />) assert.match(activeRunMarkup, /aria-label="停止"/) assert.doesNotMatch(activeRunMarkup, /]*disabled/) console.log('Composer/message action regression cases passed')