/** * 用**真实 SSE 载荷**回放 F073 正文流式的整条链路(协调器 + 应用侧组装), * 断言「正文只出现一次」且版式为「卡片 → 正文 → 参考资料」。 * * 为什么要有这个脚本:`verify-answer-stream.mjs` 用的是**理想化**的窄用例(delta 拼接 == 最终全文), * 而真后端经常在 `answer_end` 里给出**比增量拼接更长**的文本(2026-09-20 那份载荷:1309 → 1610 字, * 末尾补了追问建议)。这条路径上「应用侧记录的流式正文」会与上屏内容脱节 → 重排时按兜底分支处理 * → **正文上屏两份、卡片被挤到两份正文中间**。用户从截图报告,用这个脚本复现并定位。 * * 那条路径的回归现已用**合成载荷**钉在 `verify-answer-stream.mjs` 的【12】(不依赖外部文件); * 本脚本的价值是**回放真抓包**——真载荷里 delta 片数、heartbeat 夹带、卡片/参考资料事件序 * 都是合成的近似,出问题时先跑它,能直接看出是哪一段与预期不符。 * * 怎么跑(先按 `_entry-coordinator.ts` 头部注释重新打包 `_coordinator.mjs`): * node harness/tools/verify-answer-stream-payload.mjs # 用下面的合成夹具 * node harness/tools/verify-answer-stream-payload.mjs <原始SSE文件> # 回放真抓包 * 载荷怎么来:浏览器 Network 面板把 `/api/chat` 的响应另存为文件(保持 `event: / data: ` 原样)。 * ⚠️ 真抓包不入库(单份 600KB 量级):想回放真载荷就显式传路径。 */ import { readFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); /** 默认夹具:与真抓包**同形态**(answer_end 比增量拼接多一截尾巴)的合成载荷,小到可以入库 */ const DEFAULT_FIXTURE = join(here, 'fixtures', 'answer-stream-tail.payload.md'); const payloadPath = process.argv[2] || DEFAULT_FIXTURE; const { ApiChatCoordinator, applyAnswerStreamEnd, composeAroundStreamBody, resolveStreamedBodyAfterEnd, normalizeChatMarkdown, } = await import('./_coordinator.mjs'); let failed = 0; const check = (label, ok, detail) => { console.log(`${ok ? ' ✅' : ' ❌'} ${label}`); if (!ok) { failed++; if (detail !== undefined) { const text = typeof detail === 'string' ? detail : JSON.stringify(detail); console.log(` 实际:${String(text).slice(0, 240)}`); } } }; if (!existsSync(payloadPath)) { console.error(`载荷文件不存在:${payloadPath}`); process.exit(2); } const raw = readFileSync(payloadPath, 'utf8'); /* ------------------------------------------------------------------ * * ① 载荷统计:先把「增量拼接 vs answer_end 全文」这条关键差异摆出来 * ------------------------------------------------------------------ */ const parseEvents = (text) => text .split(/\r?\n\r?\n+/) .map((block) => block.match(/^event: (\S+)\r?\ndata: ([\s\S]*)$/)) .filter(Boolean) .map((m) => { let data = {}; try { data = JSON.parse(m[2]).data || {}; } catch { /* 坏帧忽略 */ } return { name: m[1], data }; }); const events = parseEvents(raw); const counts = {}; for (const e of events) counts[e.name] = (counts[e.name] || 0) + 1; const joined = events .filter((e) => e.name === 'answer_delta') .map((e) => e.data.text || '') .join(''); const endEvent = events.find((e) => e.name === 'answer_end'); const endText = endEvent?.data.text || ''; const normalizedFinal = normalizeChatMarkdown(endText); console.log(`载荷:${payloadPath}`); console.log('事件计数:', JSON.stringify(counts)); console.log( `增量拼接 ${joined.length} 字 / answer_end ${endText.length} 字 / 规整后 ${normalizedFinal.length} 字` + `(末尾额外 ${endText.length - joined.length} 字)` ); /* ------------------------------------------------------------------ * * ② 回放:真实协调器 + 应用侧监听器(与 useBusinessAssistantChat 同逻辑) * ------------------------------------------------------------------ */ globalThis.fetch = async () => new Response( new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(raw)); controller.close(); }, }), { status: 200, headers: { 'Content-Type': 'text/event-stream' } } ); const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 'replay' }); /** 应用侧的 per-task 流状态(对应 useBusinessAssistantChat 里的 `stream`) */ const stream = { active: false, streamId: '', body: '' }; /** 应用侧的消息内容(对应 ai.content) */ let content = ''; const channelOrder = []; coordinator.addEventListener('answerStreamStart', (p) => { stream.streamId = String(p?.streamId || ''); stream.body = ''; stream.active = true; channelOrder.push('streamStart'); }); // 增量走 message 通道上屏(流未开始时:卡片;流结束后:尾部追加) coordinator.addEventListener('message', (msg) => { if (stream.active) { stream.body += msg; channelOrder.push(`message:${msg.slice(0, 16)}`); } else if (!content.includes('POLICY_TABLE') && msg.includes('POLICY_TABLE')) { channelOrder.push('message:cards'); } else { channelOrder.push('append:trailing'); } content += msg; }); coordinator.addEventListener('answerStreamEnd', (p) => { if (!stream.active || String(p?.streamId || '') !== stream.streamId) return; const before = content; const finalText = String(p?.text ?? ''); const next = applyAnswerStreamEnd(before, stream.body, finalText); if (next !== before) content = next; stream.body = resolveStreamedBodyAfterEnd(stream.body, finalText); stream.active = false; channelOrder.push('streamEnd'); }); coordinator.addEventListener('answerStreamCompose', (p) => { const before = content; const body = stream.body; const { next, matched } = composeAroundStreamBody( before, body, String(p?.leading || ''), String(p?.trailing || ''), String(p?.replacedLeading || '') ); content = next; channelOrder.push(`compose(matched=${matched})`); }); /** 载荷里有没有 `done`(没有 = 抓包不完整,协调器走断流兼容路径,不发 totalResponse) */ const hasDone = events.some((e) => e.name === 'done'); const errors = []; const total = await new Promise((resolve) => { coordinator.addEventListener('totalResponse', (p) => resolve(p)); coordinator.addEventListener('error', (p) => errors.push(p)); void coordinator.generateAnswer('回放'); setTimeout(() => resolve(null), hasDone ? 15000 : 500); }); await new Promise((r) => setTimeout(r, 200)); if (errors.length) { console.log(`协调器 error:${errors.map((e) => e.code).join(',')}(载荷无 done 时属预期)`); } /* ------------------------------------------------------------------ * * ③ 断言 * ------------------------------------------------------------------ */ console.log('\n【1】正文只出现一次'); // 用正文里一段**有辨识度**的中段做计数(取规整后全文的 1/3 处 40 字) const probeStart = Math.floor(normalizedFinal.length / 3); const probe = normalizedFinal.slice(probeStart, probeStart + 40).trim(); const bodyCount = probe ? content.split(probe).length - 1 : 0; check(`正文中段片段在最终内容里出现 1 次(片段 ${JSON.stringify(probe.slice(0, 20))})`, bodyCount === 1, `出现 ${bodyCount} 次`); const headCount = content.split(normalizedFinal.slice(0, 20)).length - 1; check(`正文开头 20 字出现 1 次`, headCount === 1, `出现 ${headCount} 次`); check( `正文全文出现 1 次`, normalizedFinal ? content.split(normalizedFinal).length - 1 === 1 : true, `出现 ${normalizedFinal ? content.split(normalizedFinal).length - 1 : 0} 次` ); console.log('\n【2】版式顺序:卡片 → 正文 → 参考资料'); const atCard = content.indexOf('POLICY_TABLE'); const atBody = normalizedFinal ? content.indexOf(normalizedFinal.slice(0, 20)) : -1; const atRefs = content.indexOf(''); check('含政策卡片标记', atCard >= 0); check('含参考资料标记', atRefs >= 0); check( '卡片在正文之前', atCard >= 0 && atBody >= 0 && atCard < atBody, `卡片@${atCard} 正文@${atBody}` ); check('正文在参考资料之前', atBody >= 0 && atRefs >= 0 && atBody < atRefs, `正文@${atBody} 参考资料@${atRefs}`); // 人眼可读的上屏结构:按出现位置列出各内容块,便于和截图对照 { const marks = [ ['思考中卡片 ', '', ''], ['企业微信二维码 ', ''], ] .map(([label, needle]) => ({ label, at: content.indexOf(needle) })) .filter((m) => m.at >= 0) .sort((a, b) => a.at - b.at); const bodyAt = normalizedFinal ? content.indexOf(normalizedFinal.slice(0, 20)) : -1; const rows = [...marks, ...(bodyAt >= 0 ? [{ label: `正文(${normalizedFinal.length} 字)`, at: bodyAt }] : [])].sort( (a, b) => a.at - b.at ); console.log(' 上屏结构(按位置):'); for (const r of rows) console.log(` @${String(r.at).padStart(6)} ${r.label}`); console.log(` 上屏内容总长:${content.length} 字`); } console.log('\n【3】收尾链路(F079 顺序下应当**没有整块重排**)'); // 卡片在 answer_start 之前就上屏了 → 收尾只需把尾部纯追加,不该发 answerStreamCompose。 // 发了 compose 就意味着应用层要整块重写 → 用户会看到「正文先渲一遍,再从头渲一遍」。 check( '**没有发出 answerStreamCompose**(不整块重写 → 界面不重渲)', !channelOrder.some((x) => x.startsWith('compose')), `收到 ${channelOrder.filter((x) => x.startsWith('compose')).length} 次` ); check( '尾部(参考资料)作为独立追加发出', channelOrder.some((x) => x === 'append:trailing'), channelOrder.at(-1) ); if (hasDone) { check('totalResponse 里正文出现 1 次', !!total && !!normalizedFinal && String(total.answer || '').split(normalizedFinal).length - 1 === 1, total ? `出现 ${String(total.answer || '').split(normalizedFinal).length - 1} 次` : '未收到 totalResponse'); } else { console.log(' ⏭️ 载荷没有 done 事件(走断流兼容路径,不发 totalResponse)——跳过该断言'); } console.log('\n【4】兜底路径回归:流式正文没同步到上屏长度(= 本轮修的 bug)也不能重复'); { // 直接调纯函数,喂「上屏内容 = 完整正文,记录的正文 = 短一截的前缀」这种错位状态 const staleBody = joined ? normalizeChatMarkdown(joined) : ''; const { next, matched } = composeAroundStreamBody( normalizedFinal, staleBody, '卡片', '参考' ); const dup = staleBody ? next.split(staleBody).length - 1 : 0; const head = normalizedFinal.slice(0, 20); check('后缀不匹配时落兜底分支(matched=false)', matched === false, `matched=${matched}`); check('兜底分支里正文仍只出现 1 次(不重复插入)', dup === 1, `出现 ${dup} 次`); check('兜底分支里正文没被丢掉', next.includes(head)); check( '兜底分支里卡片仍在正文之前', next.indexOf('POLICY_TABLE') < next.indexOf(head), `卡片@${next.indexOf('POLICY_TABLE')} 正文@${next.indexOf(head)}` ); check( '兜底分支里参考资料仍在正文之后', next.indexOf('') > next.indexOf(head), `正文@${next.indexOf(head)} 参考@${next.indexOf('')}` ); } console.log(`\n${failed === 0 ? '✅ 全部通过' : `❌ ${failed} 项未通过`}`); process.exit(failed === 0 ? 0 : 1);