/** * 验证适配层把 `result` 事件里的 **company_info 原样带出**(不解析、不渲染进正文)。 * * 为什么必须测:企业信息同步的第一步就是「拿到 company_info」—— * 原先 `case 'result'` 是个**空分支**,整个 data 被丢弃;本次改成暂存并在 done 时 * 随 `totalResponse` 交给应用侧。这条通路没有别的地方能覆盖(DMS 侧验证是从 * 「已经拿到分类结果」开始的),所以用假 SSE 流喂真实协调器来钉住。 * * 不联网:把 `globalThis.fetch` 打桩成返回一段构造好的 SSE 流。 * * 怎么跑(先按 _entry-coordinator.ts 的说明重新打包): * npx esbuild harness/tools/_entry-coordinator.ts --bundle --format=esm \ * --outfile=harness/tools/_coordinator.mjs --alias:@=./src --define:import.meta.env='{}' * node harness/tools/verify-company-info-passthrough.mjs */ import { ApiChatCoordinator } from './_coordinator.mjs'; let pass = 0; let fail = 0; const check = (name, cond, extra = '') => { if (cond) { pass++; console.log(` ok ${name}`); } else { fail++; console.log(` FAIL ${name}${extra === '' ? '' : ` → ${JSON.stringify(extra)}`}`); } }; const COMPANY_INFO = { company: { KeyNo: 'k1', Name: '上海测试科技有限公司', CreditCode: 'TESTCODE1', OperName: '张三' }, profile: { data: { Name: '上海测试科技有限公司', CreditCode: 'TESTCODE1', OperName: '张三' }, source: { provider: '企查查' } }, honors: { records: [], sources: [], complete: true, error: null }, }; const sse = (events) => events .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`) .join(''); /** 把一段 SSE 文本打桩成 fetch 的响应 */ const stubFetchWith = (sseText) => { globalThis.fetch = async () => { const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(sseText)); controller.close(); }, }); return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); }; }; /** 把一轮里收集到的 message 事件拼成整条内容 */ const seen_message_text = (seen) => (seen.messages || []).join('\n\n'); const runTurn = async (events) => { stubFetchWith(sse(events)); const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' }); const seen = { totalResponse: null, messages: [] }; coordinator.addEventListener('totalResponse', (payload) => (seen.totalResponse = payload)); coordinator.addEventListener('message', (msg) => seen.messages.push(msg)); await coordinator.generateAnswer('测试问题'); // flushContent 会等两帧再 emit totalResponse,给它一点时间 await new Promise((resolve) => setTimeout(resolve, 60)); return seen; }; const BASE_EVENTS = [ ['accepted', { message: '收到' }], ['answer', { text: '这是回答正文。' }], ]; console.log('【1】result 带 company_info → totalResponse 原样带出'); { const seen = await runTurn([ ...BASE_EVENTS, ['result', { response: '这是回答正文。', company_info: COMPANY_INFO, recommendation: null, errors: [] }], ['done', { status: 'completed' }], ]); check('收到了 totalResponse', !!seen.totalResponse, seen.totalResponse); check('payload 里有 company_info', !!seen.totalResponse?.company_info); check( '内容与后端下发**逐字段一致**(含嵌套的 company/profile/honors)', JSON.stringify(seen.totalResponse?.company_info) === JSON.stringify(COMPANY_INFO), seen.totalResponse?.company_info ); check( '**没有混进渲染内容**(消息里不应出现 credit code)', !seen.messages.join('').includes('TESTCODE1'), seen.messages.map((m) => m.slice(0, 60)) ); } console.log('\n【2】result 不带 company_info → payload 里也不该有这个键'); { const seen = await runTurn([ ...BASE_EVENTS, ['result', { response: '这是回答正文。', recommendation: null, errors: [] }], ['done', { status: 'completed' }], ]); check('没有 company_info 键(而不是 null)', seen.totalResponse && !('company_info' in seen.totalResponse), seen.totalResponse); } console.log('\n【3】company_info 为 null / 空对象 → 不往下传(省得白跑一次分类)'); { const seenNull = await runTurn([ ...BASE_EVENTS, ['result', { response: 'x', company_info: null }], ['done', { status: 'completed' }], ]); check('company_info=null → 不传', seenNull.totalResponse && !('company_info' in seenNull.totalResponse)); const seenEmpty = await runTurn([ ...BASE_EVENTS, ['result', { response: 'x', company_info: {} }], ['done', { status: 'completed' }], ]); // 空对象会带出去(适配层不做业务判断),但下游的 isCompanyInfoUsable 会静默跳过 check('company_info={} → 带出去交给下游判断', !!seenEmpty.totalResponse?.company_info, seenEmpty.totalResponse?.company_info); } console.log('\n【4】跨轮不串:上一轮有 company_info,下一轮没有 → 不该沿用'); { stubFetchWith( sse([ ...BASE_EVENTS, ['result', { response: 'x', company_info: COMPANY_INFO }], ['done', { status: 'completed' }], ]) ); const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' }); const payloads = []; coordinator.addEventListener('totalResponse', (payload) => payloads.push(payload)); await coordinator.generateAnswer('第一轮'); await new Promise((resolve) => setTimeout(resolve, 60)); stubFetchWith( sse([ ...BASE_EVENTS, ['result', { response: 'x' }], // 这一轮没有 company_info ['done', { status: 'completed' }], ]) ); await coordinator.generateAnswer('第二轮'); await new Promise((resolve) => setTimeout(resolve, 60)); check('两轮各收到一次 totalResponse', payloads.length === 2, payloads.length); check('第一轮带 company_info', !!payloads[0]?.company_info); check('第二轮**没有**沿用上一轮的 company_info(resetTurnContent 生效)', !('company_info' in (payloads[1] || {})), payloads[1]); } console.log('\n【5】result.cipa → 企业微信二维码块()'); { const shown = await runTurn([ ...BASE_EVENTS, ['result', { response: '这是回答正文。', cipa: true }], ['done', { status: 'completed' }], ]); const text = seen_message_text(shown); check('cipa=true → 追加 块', /[\s\S]*?<\/image-scope>/.test(text), text.slice(-120)); check('块里有非空的图片地址', /\s*\S[^\n]*\s*<\/image-scope>/.test(text)); check('正文本身没被污染(正文仍在最前面)', text.trimStart().startsWith('这是回答正文。'), text.slice(0, 40)); // 二维码上方的一句引导语(用户 2026-09-20 指定文案),必须在图片**之前** const TIP = '如果您还有其他疑问,欢迎随时向我咨询,也可以联系CIPA海外服务平台获取支持。'; check('二维码上方有引导语', text.includes(TIP), text.slice(-160)); check('引导语排在图片之前', text.indexOf(TIP) > -1 && text.indexOf(TIP) < text.indexOf(''), { tip: text.indexOf(TIP), img: text.indexOf(''), }); // 位置:正文 → 二维码 → 参考资料(用户 2026-09-20 指定「二维码放在参考资料之前」) const withSources = await runTurn([ ...BASE_EVENTS, // 参考资料块要求来源**有链接**(见 buildRefLinksContent 的 filter) ['source', { id: 's1', title: '测试政策', type: 'policy', source: { url: 'https://example.com/p1' } }], ['result', { response: '这是回答正文。', cipa: true }], ['done', { status: 'completed' }], ]); const ordered = seen_message_text(withSources); const qrAt = ordered.indexOf(''); const refAt = ordered.indexOf(' -1, ordered.slice(-160)); check('二维码在**参考资料之前**', qrAt > -1 && refAt > -1 && qrAt < refAt, { qrAt, refAt }); check('二维码在正文之后', bodyAt > -1 && qrAt > bodyAt, { bodyAt, qrAt }); check('参考资料仍排在整条内容的最末尾', ordered.trimEnd().endsWith('')); const off = await runTurn([ ...BASE_EVENTS, ['result', { response: '这是回答正文。', cipa: false }], ['done', { status: 'completed' }], ]); check('cipa=false → 不加图片块', !seen_message_text(off).includes('image-scope')); const missing = await runTurn([ ...BASE_EVENTS, ['result', { response: '这是回答正文。' }], ['done', { status: 'completed' }], ]); check('result 里没有 cipa 字段 → 不加图片块', !seen_message_text(missing).includes('image-scope')); const truthyString = await runTurn([ ...BASE_EVENTS, ['result', { response: 'x', cipa: 'true' }], ['done', { status: 'completed' }], ]); check('非布尔值一律当 false(严格 === true)', !seen_message_text(truthyString).includes('image-scope')); // ⚠️ 补问轮不展示(用户 2026-09-20 从截图指出): // 界面那时是「补问卡片」,还没给出正文回答,贴二维码既突兀也没意义 const INTERRUPT = { kind: 'company_need', status: 'not_found', question: '本次未检索到与您提供的名称或代码匹配的企业。', input_help: "请核对并补充工商注册全名或统一社会信用代码;也可以输入'跳过公司查询'。", }; const withInterrupt = await runTurn([ ...BASE_EVENTS, ['interrupt', INTERRUPT], ['result', { response: 'x', cipa: true }], ['done', { status: 'needs_input' }], ]); const interruptText = seen_message_text(withInterrupt); check('补问轮:选项卡仍正常出现', interruptText.includes(''), interruptText.slice(0, 80)); check('**补问轮即使 cipa=true 也不展示二维码**', !interruptText.includes('image-scope'), interruptText.slice(-80)); const withCandidates = await runTurn([ ...BASE_EVENTS, ['interrupt', { kind: 'company_selection', status: 'found', question: '请选择您所指的公司', result: { candidates: [{ Name: '甲公司', CreditCode: 'C1' }] } }], ['result', { response: 'x', cipa: true }], ['done', { status: 'needs_input' }], ]); check('候选确认轮同样不展示二维码', !seen_message_text(withCandidates).includes('image-scope')); } console.log('\n【6】cipa 跨轮不串(resetTurnContent 生效)'); { stubFetchWith( sse([ ...BASE_EVENTS, ['result', { response: '第一轮', cipa: true }], ['done', { status: 'completed' }], ]) ); const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' }); const rounds = []; coordinator.addEventListener('message', (msg) => rounds.push(msg)); await coordinator.generateAnswer('第一轮'); await new Promise((resolve) => setTimeout(resolve, 60)); stubFetchWith( sse([ ...BASE_EVENTS, ['result', { response: '第二轮' }], // 这一轮 cipa 缺省 ['done', { status: 'completed' }], ]) ); await coordinator.generateAnswer('第二轮'); await new Promise((resolve) => setTimeout(resolve, 60)); check('第一轮有图片块', //.test(rounds[0] || '')); check('第二轮**没有**沿用上一轮的 cipa', !//.test(rounds[1] || ''), (rounds[1] || '').slice(-80)); } console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`); process.exit(fail ? 1 : 0);