Files
ycode/scripts/composer-regression.tsx

243 lines
17 KiB
TypeScript

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 { messageCapabilityRefs } from '../src/renderer/src/workbuddy/conversation/message-capabilities'
import { draftFingerprint, draftIsEmpty, draftPlainText, mergeFailedDraft } from '../src/renderer/src/workbuddy/composer/draft-fingerprint'
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 }
assert.deepEqual(messageCapabilityRefs({ ...userMessage, skills: [{ id: 'skill-a', name: 'Skill A', uri: '/skill-a' }],
mcpServerIds: ['mcp-a'], command: 'compact' }), { skillIds: ['skill-a'], mcpServerIds: ['mcp-a'], command: 'compact' },
'editing and regenerating must preserve message-scoped capability references')
const compactDraft = { text: '', command: 'compact' as const }
assert.notEqual(draftFingerprint(compactDraft), draftFingerprint({ text: '/compact', command: 'compact' }),
'compact draft identity must use the real empty editor snapshot, not the synthetic wire text')
assert.equal(draftIsEmpty({ text: '', userContent: [{ type: 'resource', id: 'C:/work/a.ts' }] }), false,
'rich-only drafts are not empty')
assert.equal(draftIsEmpty({ text: '', userContent: [{ type: 'text', text: ' ' }] }), true,
'empty rich text does not keep a draft alive')
assert.equal(draftPlainText({ text: '', userContent: [{ type: 'text', text: 'rich-only text' }] }), 'rich-only text',
'rich-only textual content reaches the send payload')
const failedDraft = {
text: 'old request',
attachments: [{ id: 'attachment-a', name: 'a.txt', mimeType: 'text/plain', size: 1 }],
resources: ['C:/work/a.ts', 'C:/work/shared.ts'],
quotes: [{ id: 'quote-a', kind: 'table' as const, sourceId: 'source-a', markdown: 'a', preview: 'a' }],
skills: [{ id: 'skill-a', name: 'Skill A' }],
mcpServerIds: ['mcp-a']
}
const currentDraft = {
text: 'new request',
attachments: [
{ id: 'attachment-b', name: 'b.txt', mimeType: 'text/plain', size: 1 },
{ id: 'attachment-a', name: 'duplicate.txt', mimeType: 'text/plain', size: 1 }
],
resources: ['C:/work/b.ts', 'C:/work/shared.ts'],
quotes: [{ id: 'quote-b', kind: 'table' as const, sourceId: 'source-b', markdown: 'b', preview: 'b' }],
skills: [{ id: 'skill-b', name: 'Skill B' }, { id: 'skill-a', name: 'Duplicate' }],
mcpServerIds: ['mcp-b', 'mcp-a']
}
const mergedDraft = mergeFailedDraft(currentDraft, failedDraft)
assert.equal(mergedDraft.draft.text, 'old request\nnew request', 'failed content must stay before subsequent input')
assert.deepEqual(mergedDraft.draft.attachments?.map(item => item.id), ['attachment-a', 'attachment-b'])
assert.deepEqual(mergedDraft.draft.resources, ['C:/work/a.ts', 'C:/work/shared.ts', 'C:/work/b.ts'])
assert.deepEqual(mergedDraft.draft.quotes?.map(item => item.id), ['quote-a', 'quote-b'])
assert.deepEqual(mergedDraft.draft.skills?.map(item => item.id), ['skill-a', 'skill-b'])
assert.deepEqual(mergedDraft.draft.mcpServerIds, ['mcp-a', 'mcp-b'])
assert.equal(mergedDraft.commandConflict, false)
const orderedFailure = mergeFailedDraft({ text: 'later' }, {
text: 'beforeafter', resources: ['C:/work/inline.ts'],
userContent: [{ type: 'text', text: 'before' }, { type: 'resource', id: 'C:/work/inline.ts' }, { type: 'text', text: 'after' }]
})
assert.deepEqual(orderedFailure.draft.userContent, [
{ type: 'text', text: 'before' }, { type: 'resource', id: 'C:/work/inline.ts' }, { type: 'text', text: 'after\nlater' }
], 'failed draft keeps inline reference order before subsequent input')
const commandFailure = mergeFailedDraft({ text: 'next ordinary draft' }, { text: '', command: 'compact' })
assert.equal(commandFailure.draft.command, 'compact', 'failed command tag remains removable instead of being discarded')
assert.equal(commandFailure.commandConflict, true, 'mixed command and ordinary content must be surfaced as a conflict')
assert.equal(mergeFailedDraft({ text: 'new' }, { text: '', userContent: [{ type: 'text', text: 'old rich text' }] }).draft.text,
'old rich text\nnew', 'failed rich-only text is not lost when subsequent input exists')
const failedQueueEdit = mergeFailedDraft({ text: 'typed after submit' }, { text: 'queued edit', queueEditId: 'queue-item-a' })
assert.equal(failedQueueEdit.draft.queueEditId, 'queue-item-a', 'failed queue-edit submissions retain the durable handoff identity')
assert.equal(draftFingerprint({ text: 'queued edit', queueEditId: 'queue-item-a' }), draftFingerprint({ text: 'queued edit' }),
'queue handoff identity never changes the submitted-content fingerprint')
const userClipboard = buildUserMessageClipboard({ ...userMessage, content: '<plain>&\u200B' })
assert.equal(userClipboard.plainText, '<plain>&', 'user text/plain omits the source zero-width marker')
assert.equal(userClipboard.html, '&lt;plain&gt;&amp;\u200B')
assert.equal(userClipboard.customMimeType, USER_CONTENT_BLOCKS_MIME)
assert.deepEqual(JSON.parse(userClipboard.customData), { version: '1.0', blocks: [{ type: 'text', text: '<plain>&\u200B' }] })
const richClipboard = buildUserMessageClipboard({
...userMessage,
content: 'see <img>&\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<string, unknown> }>
}
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 <img>&preview.pngnotes.pdf',
'plain text should summarize context links once and append attachment phrases')
assert.ok(richClipboard.html.includes('see &lt;img&gt;&amp;\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']), {
text: 'prompt', attachments: [], resources: ['C:/work/file.ts'], quotes: []
})
const quotedPayload = buildComposerSendPayload('', [], [], [
{ 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(<MessageActionToolbar message={assistant} canRegenerate isSessionActive isLastAssistant onRegenerate={() => undefined} />)
assert.match(assistantMarkup, /复制/)
assert.match(assistantMarkup, /重新生成/)
assert.doesNotMatch(assistantMarkup, /编辑/)
// 用户消息工具条已替换为原版 .cr-toolbar(时间 + 复制 + 恢复此版本 + 编辑,不含重新发送)。
const userMarkup = renderToStaticMarkup(<UserMessageToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: Date.now() }} canEdit onEdit={() => 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(<UserMessageEditor message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onSubmit={async () => 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, />取消</)
assert.match(editorMarkup, />发送</)
assert.doesNotMatch(editorMarkup, /role="alert"/, '有内容时不显示错误')
const emptyEditorMarkup = renderToStaticMarkup(<UserMessageEditor message={{ id: 'user', role: 'user', content: '', createdAt: 1 }} onSubmit={async () => undefined} onCancel={() => undefined} />)
assert.match(emptyEditorMarkup, /消息内容不能为空/)
assert.match(emptyEditorMarkup, /role="alert"/)
assert.match(emptyEditorMarkup, /cr-user-message-editor__send" disabled/)
const composerMarkup = renderToStaticMarkup(
<ComposerControls value="draft" onChange={() => undefined} onSend={() => undefined} modelSlot={<span>Model slot</span>} workspaceFooter={<span>Workspace slot</span>} sending />
)
assert.match(composerMarkup, /语音转写服务不可用/, 'without an ASR callback, voice input reports unavailable')
assert.doesNotMatch(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('cr-input-toolbar__right'))
assert.ok(composerMarkup.indexOf('cr-input-toolbar__right') < composerMarkup.indexOf('wb-composer__workspace-footer'), 'workspace footer remains outside the input toolbar')
const activeRunMarkup = renderToStaticMarkup(<ComposerControls value="next draft" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
assert.match(activeRunMarkup, /aria-label="停止当前运行"/)
assert.match(activeRunMarkup, /aria-label="发送"/)
const emptyActiveRunMarkup = renderToStaticMarkup(<ComposerControls value="" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
assert.match(emptyActiveRunMarkup, /aria-label="停止"/)
assert.doesNotMatch(emptyActiveRunMarkup, /aria-label="发送"/)
assert.doesNotMatch(activeRunMarkup, /<textarea[^>]*disabled/)
console.log('Composer/message action regression cases passed')