ソースを参照

feat(chat): 卡片改为正文开流前上屏(F079),消除「渲一遍再从头渲一遍」

用户症状:正文先开始流式渲染,过了一会儿从头重渲一遍,这次包含「卡片 → 正文 → 参考资料」;
第二次是对的,但第一次根本不该存在。

根因不是实现细节出错,而是漏读了契约新增的一节:token-streaming.md 的
「卡片先于正文(F079,2026-09-20)」明确要求——有入选政策时后端先把完整 source/item
发完才开始 answer_start/answer_delta,前端**收到 item 即可展示卡片**,不要等 summary/result。
而实现一直在 done 时才发卡片(flushContent),与已经流出来的正文顺序冲突,只能靠
answerStreamCompose 整块重写把卡片插到正文之上;渲染层遇到非纯追加会全量重解析,
于是用户看到「渲一遍 → 从头再渲一遍」。

修法(渲染层零改动):
- 新增 emitCardsBeforeStream():在 answer_start 时先把卡片块发上屏。契约保证此刻
  item 已发完,卡片集合完整;不选「收到 item 就发」是因为无法知道哪条是最后一条,
  提前发会有「先发一半、后面再补」的替换问题。
- flushContent 分两路:卡片没提前上屏 → 仍走 answerStreamCompose 整块重排(兜底);
  卡片已上屏 → 尾部(参考资料等)纯追加,不发 compose,界面不重写、不闪。
  totalResponse 仍按上屏顺序拼(开流前的卡片 + 其余 leading + 正文 + 尾部)。
- 「卡片已上屏」判据不带 streaming:撤稿/断流后 answerStream 会被重置、streaming 变回 false,
  那时卡片其实已上屏,会被当新内容再发一份(界面上两份卡片)。
- answerStreamCompose 增加 replacedLeading:开流后卡片集合变大时先抹掉屏幕上的旧块,
  避免新旧两份并存。

验证:verify-answer-stream 131/131(新增【13】–【18】,每条都实测过「阉掉修复即失败」);
抓包回放全绿(夹具改为 F079 顺序,上屏结构 scope→卡片→正文→参考资料,无 compose);
回归全绿(适配层 27/27、多实例 11/11、判空口径 30/30、聊天任务 33/33、政策卡列表 24/24);
npm run build 与 validate-harness 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 9 時間 前
コミット
c7b80ed34e

ファイルの差分が大きいため隠しています
+ 3 - 3
harness/feature_list.json


+ 48 - 0
harness/progress.md

@@ -3122,3 +3122,51 @@ abort 对不上时不动);`npm run build` 通过。
 
 **⚠️ 待浏览器确认**:修后一轮完整问答应为「卡片 → 正文 → 参考资料」,正文只出现一次。
 若仍看到重复,请把控制台的 `[answer-stream] compose` 一行发来(`matched` 是否为 true、三个长度)。
+
+### 续 6:卡片改为**开流前上屏**(用户报「正文先渲一遍、再从头渲一遍」)
+
+**用户症状**:正文先开始流式渲染,过了一会儿**从头重渲一遍**,这次包含
+「卡片 → 正文 → 参考资料」。第二次是对的,但**第一次根本不该存在**。
+
+**根因(不是猜,契约里写着)**:`docs/reference/token-streaming.md` 的
+**「卡片先于正文(F079,2026-09-20)」**一节(本轮才发现它已更新)明确要求:
+有入选政策时,后端**先把完整 `source` / `item` 发完**才开始 `answer_start` / `answer_delta`;
+前端**收到 `item` 即可展示卡片**,不要等 `summary` / `result`。
+
+而我这边的实现一直在 `done` 时才发卡片(`flushContent`),与已经流出来的正文顺序冲突,
+只能靠 `answerStreamCompose` **整块重写**把卡片插到正文之上 —— 渲染层遇到非纯追加会
+`resetDisplayContent()` 全量重解析,用户看到的就是「渲一遍 → 从头再渲一遍」。
+
+**修法(协调器 + 两个纯函数,渲染层零改动)**:
+1. `emitCardsBeforeStream()`:在 `answer_start` 时**先把卡片块发上屏**(此刻卡片集合完整,
+   因为契约保证 item 已发完)。卡片因此排在正文增量之前,收尾不再需要重排。
+   时机取 `answer_start` 而不是「收到 item 就发」:无法知道哪条 item 是最后一条,
+   提前发会有「先发一半、后面再补」的替换问题;而契约保证的距离只有首个 token 的延迟(约 0.6s)。
+2. `flushContent` 拆两种情况:卡片没提前上屏 → 仍走 `answerStreamCompose` 整块重排(兜底);
+   卡片已上屏 → 尾部(参考资料等)**纯追加**,不发 compose → **界面不重写、不闪**。
+   `totalResponse` 仍按上屏顺序拼(开流前已发的卡片 + 其余 leading + 正文 + 尾部)。
+3. ⚠️ 顺带修掉两处自己引入的坑:
+   - 「卡片已上屏」的判据**不能带 `streaming`**:撤稿/断流后 `answerStream` 会被重置,
+     `streaming` 变回 false,那时卡片其实已上屏 → 会被当新内容再发一份(**界面上两份卡片**)。
+     断流路径会走 `flushContent`,这条能被测到(【17】实测:加回 `streaming` 判据即失败)。
+   - 开流后卡片集合变大(违反 F079 顺序,兜底):旧卡片块已在屏幕上,重排时会新旧并存。
+     `answerStreamCompose` 增加 `replacedLeading`,应用层重排前先把旧块抹掉。
+
+**运行过的验证**:
+- `verify-answer-stream.mjs` **131/131 通过**。新增 6 节:
+  【13】F079 顺序(item 先于 answer_start)下卡片排在正文之前、**不发 compose**;
+  【14】item 晚于 answer_start 时仍走 compose 兜底;
+  【15】应用层最终版式(卡片/正文/参考资料**各出现一次**、无重排);
+  【16】开流后卡片集合变大 → 无新旧两份;【17】断流撤稿卡片保留;【18】error 撤稿卡片保留。
+  **都实测过「把修复阉掉即失败」**(【13】6 项失败、【15】无 compose 断言失败、【17】卡片变 2 份)。
+- `verify-answer-stream-payload.mjs` 全绿:合成夹具已改为 F079 顺序(source/item 在 answer_start 前),
+  上屏结构 `@0 <scope> → @53 卡片 → @440 正文 → @564 参考资料`,**没有 answerStreamCompose**。
+- 回归全绿:适配层直通 27/27、多实例 11/11、判空口径 30/30、聊天任务 33/33、政策卡列表 24/24;
+  `npm run build` 通过;`validate-harness` exit 0。
+- 顺带用真实 `StreamXMLFilter` 探了一次「scope 与卡片同批送达」是否会被当纯文本渲染:
+  **不会**(`processRecordSections` 会在 `<!-- POLICY_TABLE` 处切开,两批/同批结果一致),
+  所以开流前同步发卡片不需要额外让帧。
+
+**⚠️ 待浏览器确认**:带卡片的问答应当是「思考中 → 卡片 → 正文逐字流出 → 参考资料」,
+**中间不再有整块重渲**。若仍看到重渲,请把控制台 `[answer-stream] compose` 一行发来
+(有这行 = 走了兜底重排,说明卡片没能提前上屏)。

+ 79 - 79
harness/tools/fixtures/answer-stream-tail.payload.md

@@ -1,225 +1,225 @@
 event: accepted
-data: {"thread_id":"t","request_id":"r","data":{"message":"收到"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"message":"收到"}}
 
 event: progress
-data: {"thread_id":"t","request_id":"r","data":{"stage":"retrieve","status":"running","message":"正在查阅……"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stage":"retrieve","status":"running","message":"正在查阅与您咨询事项有关的政策和办事资料……"}}
+
+event: source
+data: {"thread_id":"fixture","request_id":"r","data":{"id":"s1","title":"首次创业一次性补贴","type":"policy","source":{"url":"https://example.com/p1"}}}
+
+event: item
+data: {"thread_id":"fixture","request_id":"r","data":{"source_id":"s1","title":"首次创业一次性补贴","card":{"name":{"text":"首次创业一次性补贴"}}}}
 
 event: answer_start
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":0}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":0,"provisional":true}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":1,"text":"您"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":1,"text":"您"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":2,"text":"想"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":2,"text":"想"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":3,"text":"开"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":3,"text":"开"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":4,"text":"奶"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":4,"text":"奶"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":5,"text":"茶"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":5,"text":"茶"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":6,"text":"店"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":6,"text":"店"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":7,"text":","}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":7,"text":","}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":8,"text":"我"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":8,"text":"我"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":9,"text":"们"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":9,"text":"们"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":10,"text":"青"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":10,"text":"青"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":11,"text":"浦"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":11,"text":"浦"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":12,"text":"目"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":12,"text":"目"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":13,"text":"前"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":13,"text":"前"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":14,"text":"没"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":14,"text":"没"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":15,"text":"有"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":15,"text":"有"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":16,"text":"奶"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":16,"text":"奶"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":17,"text":"茶"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":17,"text":"茶"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":18,"text":"店"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":18,"text":"店"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":19,"text":"或"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":19,"text":"或"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":20,"text":"餐"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":20,"text":"餐"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":21,"text":"饮"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":21,"text":"饮"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":22,"text":"行"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":22,"text":"行"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":23,"text":"业"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":23,"text":"业"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":24,"text":"的"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":24,"text":"的"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":25,"text":"专"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":25,"text":"专"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":26,"text":"项"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":26,"text":"项"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":27,"text":"扶"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":27,"text":"扶"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":28,"text":"持"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":28,"text":"持"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":29,"text":"。"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":29,"text":"。"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":30,"text":"\n"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":30,"text":"\n"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":31,"text":"\n"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":31,"text":"\n"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":32,"text":"#"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":32,"text":"#"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":33,"text":"#"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":33,"text":"#"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":34,"text":"#"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":34,"text":"#"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":35,"text":" "}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":35,"text":" "}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":36,"text":"首"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":36,"text":"首"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":37,"text":"次"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":37,"text":"次"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":38,"text":"创"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":38,"text":"创"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":39,"text":"业"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":39,"text":"业"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":40,"text":"一"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":40,"text":"一"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":41,"text":"次"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":41,"text":"次"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":42,"text":"性"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":42,"text":"性"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":43,"text":"补"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":43,"text":"补"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":44,"text":"贴"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":44,"text":"贴"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":45,"text":"\n"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":45,"text":"\n"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":46,"text":"\n"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":46,"text":"\n"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":47,"text":"-"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":47,"text":"-"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":48,"text":" "}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":48,"text":" "}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":49,"text":"补"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":49,"text":"补"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":50,"text":"贴"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":50,"text":"贴"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":51,"text":"标"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":51,"text":"标"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":52,"text":"准"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":52,"text":"准"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":53,"text":"为"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":53,"text":"为"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":54,"text":"*"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":54,"text":"*"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":55,"text":"*"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":55,"text":"*"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":56,"text":"一"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":56,"text":"一"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":57,"text":"次"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":57,"text":"次"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":58,"text":"性"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":58,"text":"性"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":59,"text":"8"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":59,"text":"8"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":60,"text":"0"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":60,"text":"0"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":61,"text":"0"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":61,"text":"0"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":62,"text":"0"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":62,"text":"0"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":63,"text":"元"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":63,"text":"元"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":64,"text":"*"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":64,"text":"*"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":65,"text":"*"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":65,"text":"*"}}
 
 event: answer_delta
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":66,"text":"。"}}
-
-event: source
-data: {"thread_id":"t","request_id":"r","data":{"id":"s1","title":"首次创业一次性补贴","type":"policy","source":{"url":"https://example.com/p"}}}
-
-event: item
-data: {"thread_id":"t","request_id":"r","data":{"source_id":"s1","title":"首次创业一次性补贴","card":{"name":{"text":"首次创业一次性补贴"}}}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":66,"text":"。"}}
 
 event: answer_end
-data: {"thread_id":"t","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":66,"text":"您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。\n\n2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?","operation":"replace","status":"completed"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"stream_id":"s1","target":"summary","sequence":66,"text":"您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。\n\n2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?","operation":"replace","status":"completed","provisional":false}}
 
 event: summary
-data: {"thread_id":"t","request_id":"r","data":{"text":"您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。\n\n2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?","source_ids":["s1"],"notices":[]}}
+data: {"thread_id":"fixture","request_id":"r","data":{"text":"您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。\n\n2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?","source_ids":["s1"],"notices":[]}}
 
 event: result
-data: {"thread_id":"t","request_id":"r","data":{"response":"您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。\n\n2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?","recommendation":{},"errors":[]}}
+data: {"thread_id":"fixture","request_id":"r","data":{"response":"您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。\n\n2. **补充申请人的情况**:是否已入驻青浦的孵化主基地?\n3. **确认相关时间**:是否已注册成立满1年?","recommendation":{},"errors":[]}}
 
 event: done
-data: {"thread_id":"t","request_id":"r","data":{"status":"completed"}}
+data: {"thread_id":"fixture","request_id":"r","data":{"status":"completed"}}
 

+ 22 - 9
harness/tools/verify-answer-stream-payload.mjs

@@ -118,11 +118,17 @@ coordinator.addEventListener('answerStreamStart', (p) => {
   channelOrder.push('streamStart');
 });
 
-// 增量走 message 通道上屏
+// 增量走 message 通道上屏(流未开始时:卡片;流结束后:尾部追加)
 coordinator.addEventListener('message', (msg) => {
-  if (stream.active) stream.body += 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;
-  channelOrder.push(`message:${msg.slice(0, 16)}`);
 });
 
 coordinator.addEventListener('answerStreamEnd', (p) => {
@@ -143,7 +149,8 @@ coordinator.addEventListener('answerStreamCompose', (p) => {
     before,
     body,
     String(p?.leading || ''),
-    String(p?.trailing || '')
+    String(p?.trailing || ''),
+    String(p?.replacedLeading || '')
   );
   content = next;
   channelOrder.push(`compose(matched=${matched})`);
@@ -216,12 +223,18 @@ check('正文在参考资料之前', atBody >= 0 && atRefs >= 0 && atBody < atRe
   console.log(`  上屏内容总长:${content.length} 字`);
 }
 
-console.log('\n【3】收尾链路');
-check('收到 answerStreamCompose', channelOrder.some((x) => x.startsWith('compose')), channelOrder.at(-1));
+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 === 'compose(matched=true)'),
-  channelOrder.filter((x) => x.startsWith('compose')).join(',')
+  '尾部(参考资料)作为独立追加发出',
+  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');

+ 210 - 34
harness/tools/verify-answer-stream.mjs

@@ -457,6 +457,56 @@ const runTurn = async (events) => {
 // 用 '\n\n' 拼会把流式增量切成一堆段落,断言就失真了
 const messageText = (seen) => seen.messages.join('');
 const countOf = (text, needle) => text.split(needle).length - 1;
+/** 数**卡片块**个数(`POLICY_TABLE` 在同一个块里出现两次:`<!-- POLICY_TABLE` 与 `POLICY_TABLE -->`) */
+const countCards = (text) => countOf(text, '<!-- POLICY_TABLE');
+
+/**
+ * 跑一轮**并带上应用层的监听器**(与 `useBusinessAssistantChat` 同逻辑),
+ * 返回「上屏内容」而不是原始事件 —— 用来断言用户真正看到的东西。
+ */
+const runTurnWithApp = async (events) => {
+  stubFetchWith(sse(events));
+  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
+  const stream = { active: false, streamId: '', body: '' };
+  const seen = { content: '', matched: null, compose: [], messages: [] };
+  coordinator.addEventListener('answerStreamStart', (p) => {
+    stream.streamId = String(p?.streamId || '');
+    stream.body = '';
+    stream.active = true;
+  });
+  coordinator.addEventListener('message', (msg) => {
+    if (stream.active) stream.body += msg; // 正在流式时这条通道上跑的就是正文增量
+    seen.messages.push(msg);
+    seen.content += msg;
+  });
+  coordinator.addEventListener('answerStreamEnd', (p) => {
+    if (!stream.active || String(p?.streamId || '') !== stream.streamId) return;
+    const finalText = String(p?.text ?? '');
+    seen.content = applyAnswerStreamEnd(seen.content, stream.body, finalText);
+    stream.body = resolveStreamedBodyAfterEnd(stream.body, finalText);
+    stream.active = false;
+  });
+  coordinator.addEventListener('answerStreamAbort', (p) => {
+    if (!stream.active || String(p?.streamId || '') !== stream.streamId) return;
+    seen.content = applyAnswerStreamAbort(seen.content, stream.body);
+    stream.active = false;
+  });
+  coordinator.addEventListener('answerStreamCompose', (p) => {
+    seen.compose.push(p);
+    const r = composeAroundStreamBody(
+      seen.content,
+      stream.body,
+      String(p?.leading || ''),
+      String(p?.trailing || ''),
+      String(p?.replacedLeading || '')
+    );
+    seen.content = r.next;
+    seen.matched = r.matched;
+  });
+  await coordinator.generateAnswer('回放');
+  await new Promise((r) => setTimeout(r, 120));
+  return seen;
+};
 
 const DELTA_TEXTS = ['一、', '第一段说明', '。\n\n', '二、', '第二段说明', '。'];
 const FINAL_TEXT = '一、第一段说明。\n\n二、第二段说明。';
@@ -680,7 +730,7 @@ console.log('\n【12】协调器假流 + 应用层组装:answer_end 比增量
   const FINAL = `${HEAD}\n\n${TAIL}`;
   const CHUNKS = [...HEAD]; // 逐字下发,复现真实节奏
 
-  const sseText = [
+  const seen = await runTurnWithApp([
     ['accepted', { message: '收到' }],
     ['answer_start', { stream_id: 's9', target: 'summary', sequence: 0 }],
     ...CHUNKS.map((text, i) => ['answer_delta', { stream_id: 's9', target: 'summary', sequence: i + 1, text }]),
@@ -690,39 +740,9 @@ console.log('\n【12】协调器假流 + 应用层组装:answer_end 比增量
     ['summary', { text: FINAL, notices: [] }],
     ['result', { response: FINAL, recommendation: {}, errors: [] }],
     ['done', { status: 'completed' }],
-  ]
-    .map(([event, data]) => `event: ${event}\ndata: ${JSON.stringify({ thread_id: 't1', request_id: 'r1', data })}\n\n`)
-    .join('');
-  stubFetchWith(sseText);
-
-  const coordinator = new ApiChatCoordinator({ baseUrl: 'http://stub', threadId: 't1' });
-  // ---- 以下与 useBusinessAssistantChat 的监听器同逻辑 ----
-  const stream = { active: false, streamId: '', body: '' };
-  let content = '';
-  let matched = null;
-  coordinator.addEventListener('answerStreamStart', (p) => {
-    stream.streamId = String(p?.streamId || '');
-    stream.body = '';
-    stream.active = true;
-  });
-  coordinator.addEventListener('message', (msg) => {
-    if (stream.active) stream.body += msg;
-    content += msg;
-  });
-  coordinator.addEventListener('answerStreamEnd', (p) => {
-    if (!stream.active || String(p?.streamId || '') !== stream.streamId) return;
-    const finalText = String(p?.text ?? '');
-    content = applyAnswerStreamEnd(content, stream.body, finalText);
-    stream.body = resolveStreamedBodyAfterEnd(stream.body, finalText);
-    stream.active = false;
-  });
-  coordinator.addEventListener('answerStreamCompose', (p) => {
-    const r = composeAroundStreamBody(content, stream.body, String(p?.leading || ''), String(p?.trailing || ''));
-    content = r.next;
-    matched = r.matched;
-  });
-  await coordinator.generateAnswer('回放');
-  await new Promise((r) => setTimeout(r, 120));
+  ]);
+  const content = seen.content;
+  const matched = seen.matched;
 
   const normFinal = normalizeChatMarkdown(FINAL);
   check('重排落**正常路径**(end 时同步了流式正文)', matched === true, `matched=${matched}`);
@@ -732,5 +752,161 @@ console.log('\n【12】协调器假流 + 应用层组装:answer_end 比增量
   check('卡-文-参考 顺序:参考资料在正文之后', content.indexOf('<ref_links>') > content.indexOf(normFinal.slice(0, 20)));
 }
 
+console.log('\n【13】F079 卡片先于正文:source/item 在 answer_start **之前**到(真后端顺序)');
+{
+  // 契约(token-streaming.md「卡片先于正文(F079)」):有入选政策时,完整 source/item
+  // 发完才开始 answer_start/answer_delta;前端**收到 item 即可展示卡片**,不要等 summary/result。
+  // 用户 2026-09-21 报告的就是这里没做到:卡片等 done 才发 → 只能整块重写插到正文之上 →
+  // 界面上「正文先渲一遍,然后从头再渲一遍」,第一次纯属浪费。
+  const seen = await runTurn([
+    ['accepted', { message: '收到' }],
+    ['progress', { stage: 'retrieve', message: '正在查阅……' }],
+    ['source', { id: 's1', title: '首次创业一次性补贴', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '首次创业一次性补贴', card: { name: { text: '首次创业一次性补贴' } } }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0, provisional: true }],
+    ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
+    ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, operation: 'replace', status: 'completed' }],
+    ['summary', { text: FINAL_TEXT, source_ids: ['s1'], notices: [] }],
+    ['result', { response: FINAL_TEXT, recommendation: {}, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]);
+
+  const text = messageText(seen);
+  const atCard = text.indexOf('POLICY_TABLE');
+  const atBody = text.indexOf(MARKER);
+  const atRefs = text.indexOf('<ref_links>');
+  check('卡片在 message 通道里出现', atCard >= 0);
+  check('**卡片排在正文之前**(开流前就上屏了)', atCard >= 0 && atBody >= 0 && atCard < atBody, `卡片@${atCard} 正文@${atBody}`);
+  check('正文只出现一次', countOf(text, MARKER) === 1, countOf(text, MARKER));
+  check('参考资料仍排在正文之后', atRefs > atBody, `正文@${atBody} 参考@${atRefs}`);
+  check(
+    '**不发出 answerStreamCompose**(卡片已在正文之前,不需要整块重写 → 界面上不会重渲一遍)',
+    seen.compose.length === 0,
+    seen.compose
+  );
+  // 尾部必须是**独立的一段**追加(不是混在正文增量里),否则渲染层会把它当正文
+  const lastChunk = seen.messages[seen.messages.length - 1] || '';
+  check(
+    '尾部(参考资料)作为**独立追加**发出,且不夹带正文',
+    lastChunk.includes('<ref_links>') && !lastChunk.includes(MARKER),
+    JSON.stringify(lastChunk.slice(0, 60))
+  );
+  check('尾部追加发生在正文之后(上屏内容以参考资料结尾)', text.trimEnd().endsWith('</ref_links>'), JSON.stringify(text.slice(-40)));
+  check(
+    'totalResponse 里卡片仍排在正文之前',
+    (() => {
+      const ans = String(seen.total?.answer || '');
+      return ans.indexOf('POLICY_TABLE') > -1 && ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER);
+    })(),
+    String(seen.total?.answer || '').slice(0, 80)
+  );
+  check('totalResponse 与上屏内容顺序一致(卡片 → 正文 → 参考资料)', (() => {
+    const ans = String(seen.total?.answer || '');
+    return ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER) && ans.indexOf(MARKER) < ans.indexOf('<ref_links>');
+  })(), String(seen.total?.answer || '').slice(0, 120));
+}
+
+console.log('\n【14】F079 的兜底:item 晚于 answer_start(旧后端或后端顺序变化)');
+{
+  const seen = await runTurn([
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
+    ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
+    ['item', { source_id: 's1', title: '迟到卡片', card: { name: { text: '迟到卡片' } } }],
+    ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL_TEXT, status: 'completed' }],
+    ['summary', { text: FINAL_TEXT }],
+    ['result', { response: FINAL_TEXT }],
+    ['done', { status: 'completed' }],
+  ]);
+  const text = messageText(seen);
+  check('开流时没有卡片 → 仍发出 answerStreamCompose 兜底', seen.compose.length === 1, seen.compose.length);
+  check('兜底 compose 的 leading 是卡片', String(seen.compose[0]?.leading || '').includes('POLICY_TABLE'), String(seen.compose[0]?.leading || '').slice(0, 40));
+  check('卡片只出现一次(没有既提前发、又兜底发)', countCards(text) === 0, `message 通道出现 ${countCards(text)} 次(走 compose 时为 0)`);
+  check('totalResponse 里卡片仍在正文之前', (() => {
+    const ans = String(seen.total?.answer || '');
+    return ans.indexOf('POLICY_TABLE') > -1 && ans.indexOf('POLICY_TABLE') < ans.indexOf(MARKER);
+  })(), String(seen.total?.answer || '').slice(0, 80));
+}
+
+console.log('\n【15】应用层:F079 顺序下的最终版式(卡片 → 正文 → 参考资料,且都只出现一次)');
+{
+  const HEAD = '您想开奶茶店,我们青浦目前没有奶茶店或餐饮行业的专项扶持。\n\n### 首次创业一次性补贴\n\n- 补贴标准为**一次性8000元**。';
+  const FINAL = `${HEAD}\n\n2. **确认相关时间**:是否已注册成立满1年?`;
+  const seen = await runTurnWithApp([
+    ['progress', { stage: 'retrieve', message: '正在查阅……' }],
+    ['source', { id: 's1', title: '首次创业一次性补贴', type: 'policy', source: { url: 'https://example.com/p' } }],
+    ['item', { source_id: 's1', title: '首次创业一次性补贴', card: { name: { text: '首次创业一次性补贴' } } }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
+    ...[...HEAD].map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
+    ['answer_end', { stream_id: 's1', target: 'summary', sequence: [...HEAD].length, text: FINAL, status: 'completed' }],
+    ['summary', { text: FINAL, notices: [] }],
+    ['result', { response: FINAL, recommendation: {}, errors: [] }],
+    ['done', { status: 'completed' }],
+  ]);
+  const content = seen.content;
+  const normFinal = normalizeChatMarkdown(FINAL);
+  const head = normFinal.slice(0, 20);
+  check('卡片只出现一次', countCards(content) === 1, countCards(content));
+  check('正文只出现一次', countOf(content, normFinal) === 1, countOf(content, normFinal));
+  check('参考资料只出现一次', countOf(content, '<ref_links>') === 1, countOf(content, '<ref_links>'));
+  check('卡片 → 正文 → 参考资料', content.indexOf('POLICY_TABLE') < content.indexOf(head) && content.indexOf(head) < content.indexOf('<ref_links>'), `卡片@${content.indexOf('POLICY_TABLE')} 正文@${content.indexOf(head)} 参考@${content.indexOf('<ref_links>')}`);
+  check('**没有整块重排**(compose 一次都没发 → 界面上不会重渲一遍)', seen.compose.length === 0, seen.compose.length);
+}
+
+console.log('\n【16】应用层:卡片集合在开流后变大(契约外的兜底)→ 不能留下两份卡片');
+{
+  const FINAL = '一、第一段说明。\n\n二、第二段说明。';
+  const seen = await runTurnWithApp([
+    ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
+    ...DELTA_TEXTS.map((text, i) => ['answer_delta', { stream_id: 's1', target: 'summary', sequence: i + 1, text }]),
+    // 开流后才到的第二张卡(违反 F079 顺序;旧后端或后端改动时可能发生)
+    ['source', { id: 's2', title: '乙政策', type: 'policy', source: { url: 'https://example.com/p2' } }],
+    ['item', { source_id: 's2', title: '乙政策', card: { name: { text: '乙政策' } } }],
+    ['answer_end', { stream_id: 's1', target: 'summary', sequence: DELTA_TEXTS.length, text: FINAL, status: 'completed' }],
+    ['summary', { text: FINAL }],
+    ['result', { response: FINAL }],
+    ['done', { status: 'completed' }],
+  ]);
+  const content = seen.content;
+  check('发了 compose 兜底', seen.compose.length === 1, seen.compose.length);
+  check('compose 带上了「需要抹掉的旧卡片块」', !!String(seen.compose[0]?.replacedLeading || ''), String(seen.compose[0]?.replacedLeading || '').slice(0, 40));
+  check('**卡片块只保留一份**(旧的被抹掉,不是新旧并存)', countCards(content) === 1, countCards(content));
+  check('新卡片在内容里(乙政策)', content.includes('乙政策'));
+  check('正文只出现一次', countOf(content, MARKER) === 1, countOf(content, MARKER));
+  check('卡片仍在正文之前', content.indexOf('POLICY_TABLE') < content.indexOf(MARKER));
+}
+
+console.log('\n【17】应用层:撤稿时**已上屏的卡片保留**(契约要求),且不会被补发第二份');
+{
+  // ⚠️ 断流(EOF 无 done)这条路径**会走 flushContent** —— 那时 `answerStream` 已被
+  // `discardActiveStream` 重置,`streaming` 变回 false。若「卡片已上屏」的判据里带上
+  // `streaming`,卡片就会被当成本轮的新内容**再发一份**(界面上两份卡片)。
+  const seen = await runTurnWithApp([
+    ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
+    ['answer_delta', { stream_id: 's1', target: 'summary', sequence: 1, text: '半截草稿' }],
+    // 没有 done 就到 EOF
+  ]);
+  const content = seen.content;
+  check('卡片保留(开流前已上屏)且只有一份', countCards(content) === 1, countCards(content));
+  check('草稿已被撤掉', !content.includes('半截草稿'), content.slice(-60));
+}
+
+console.log('\n【18】应用层:后端 error(不走 flushContent)撤稿,卡片同样保留');
+{
+  const seen = await runTurnWithApp([
+    ['source', { id: 's1', title: '甲政策', type: 'policy', source: { url: 'https://example.com/p1' } }],
+    ['item', { source_id: 's1', title: '甲政策', card: { name: { text: '甲政策' } } }],
+    ['answer_start', { stream_id: 's1', target: 'summary', sequence: 0 }],
+    ['answer_delta', { stream_id: 's1', target: 'summary', sequence: 1, text: '半截草稿' }],
+    ['error', { code: 'processing_failed' }],
+  ]);
+  const content = seen.content;
+  check('卡片保留且只有一份', countCards(content) === 1, countCards(content));
+  check('草稿已被撤掉', !content.includes('半截草稿'), content.slice(-60));
+}
+
 console.log(`\n===== 总计:通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 10 - 2
src/components/answer-stream.ts

@@ -288,10 +288,18 @@ export const composeAroundStreamBody = (
   content: string,
   streamedBody: string,
   leading: string,
-  trailing: string
+  trailing: string,
+  /** 需要先从内容里抹掉的、**之前已上屏**的「正文之上」内容(卡片集合变了时用;见下) */
+  replacedLeading = ''
 ): StreamComposeResult => {
-  const before = String(content ?? '');
+  let before = String(content ?? '');
   const body = String(streamedBody ?? '');
+  const stale = String(replacedLeading ?? '');
+  // 卡片集合变了(开流后才又来了 item):旧卡片块已被重排的 `leading` 取代,先抹掉,
+  // 否则新旧两份卡片会同时留在内容里。集合没变时调用方不会传,这里是纯兜底。
+  if (stale && stale !== leading && before.includes(stale)) {
+    before = before.replace(stale, '').replace(/\n{3,}/g, '\n\n');
+  }
 
   if (!body) {
     return { next: composeStreamedContent(before, leading, '', trailing), matched: true };

+ 63 - 6
src/components/api-chat-coordinator.ts

@@ -811,7 +811,13 @@ export type ApiChatEventMap = {
    * 流式轮的**最终重排**:正文已上屏,但「卡片」要排在正文**之上**、
    * 「参考资料/二维码」在正文之下 —— 应用层按 基线 + leading + 正文 + trailing 重写一次内容。
    */
-  answerStreamCompose: { streamId: string; leading: string; trailing: string };
+  answerStreamCompose: {
+    streamId: string;
+    leading: string;
+    trailing: string;
+    /** 已在屏幕上、但已被 `leading` 取代的旧内容(卡片集合变了时非空) */
+    replacedLeading?: string;
+  };
   /** 已翻译成原界面内容标记的文本 */
   message: string;
   error: ChatErrorPayload;
@@ -859,6 +865,11 @@ export class ApiChatCoordinator {
     createIncrementalMarkdownNormalizer();
   /** 正文是否已输出(输出后不再发送 scope 进度卡片) */
   private contentFlushed = false;
+  /**
+   * F079 卡片先于正文:**开流前就已上屏**的「正文之上」内容(政策卡片块);`''` = 没有。
+   * 记下来是为了 ①`flushContent` 不重复输出 ②`totalResponse` 里卡片仍排在正文之前。
+   */
+  private preStreamLeading = '';
   /** 最近一次补问的原始数据,供提交时反查候选用 */
   lastInterruptPayload: ChatInterruptPayload | null = null;
   /** 本轮是否输出过 scope 进度标记,决定 flush 时是否需要让出一帧 */
@@ -1058,6 +1069,7 @@ export class ApiChatCoordinator {
     this.answerStream = createAnswerStreamState();
     this.answerStreamNormalizer = createIncrementalMarkdownNormalizer();
     this.contentFlushed = false;
+    this.preStreamLeading = '';
     this.scopeEmitted = false;
     this.doneReceived = false;
   }
@@ -1125,8 +1137,14 @@ export class ApiChatCoordinator {
       leading.push(normalizeChatMarkdown(this.answerText));
     }
 
+    // F079:卡片已在开流前上屏(`emitCardsBeforeStream`)→ 这里不再重复输出。
+    // 没上屏的情形有三:非流式轮(旧后端/无 answer_start)、answer_start 时还没有 item、
+    // 或开流后才又来了新 item —— 那时仍走「插到正文之上」的整块重排(见下)
     const policyTable = buildPolicyTableContent(this.pendingItems, this.pendingSources);
-    if (policyTable) {
+    // ⚠️ 判据**不能**加 `streaming`:撤稿/断流后 streaming 会变回 false,那时卡片其实已经上屏过,
+    // 再加进 trailing 就会在界面上出现第二份卡片(契约要求「草稿撤回时已校验卡片保留」)
+    const cardsAlreadyShown = !!policyTable && policyTable === this.preStreamLeading;
+    if (policyTable && !cardsAlreadyShown) {
       (streaming ? leading : trailing).push(policyTable);
     }
 
@@ -1166,15 +1184,28 @@ export class ApiChatCoordinator {
     const trailingText = trailing.filter((part) => String(part || '').trim().length > 0).join('\n\n');
 
     if (streaming) {
-      // 流式轮:正文已经上屏,但卡片要排在它**之前**,只能让应用层按三块重排一次
-      // (卡片在 answer_end 之前就齐了,这一步发生在 done,观感上是"卡片插到正文上方")
-      this.totalResponse += composeStreamedContent('', leadingText, streamBody ?? '', trailingText);
-      if (leadingText || trailingText) {
+      // totalResponse 按**上屏顺序**拼:开流前已上屏的卡片 + 仍需插到正文之上的内容 + 正文 + 尾部
+      const aboveText = [this.preStreamLeading, leadingText].filter(Boolean).join('\n\n');
+      this.totalResponse += composeStreamedContent('', aboveText, streamBody ?? '', trailingText);
+
+      if (leadingText) {
+        // 还有内容必须插到正文**之上**(卡片没能提前上屏 / 开流后才到的 item / 要保留的 answer 概述)
+        // → 只能让应用层按三块整块重排一次(内容不是纯追加,渲染层会重置重解析)
         this._eventbus.emit('answerStreamCompose', {
           streamId: this.answerStream.streamId || '',
           leading: leadingText,
           trailing: trailingText,
+          // 卡片集合变了:旧卡片块已经在屏幕上,应用层重排时要先把它抹掉(否则新旧两份并存)
+          replacedLeading: policyTable && !cardsAlreadyShown ? this.preStreamLeading : '',
         });
+      } else if (trailingText) {
+        // 卡片已在正文之前上屏:尾部**纯追加**即可 —— 应用侧不重置、不闪
+        // (让出两帧与旧路径同:确保先发出的 scope 已被解析,再追加尾部内容)
+        if (this.scopeEmitted) {
+          await this.nextFrame();
+          await this.nextFrame();
+        }
+        this._eventbus.emit('message', trailingText);
       }
       return;
     }
@@ -1198,6 +1229,29 @@ export class ApiChatCoordinator {
     this._eventbus.emit('message', content);
   }
 
+  /**
+   * F079 卡片先于正文:**开流前**把卡片上屏。
+   *
+   * 契约(`token-streaming.md`「卡片先于正文(F079)」)保证:有入选政策时,
+   * 完整 `source` / `item` 都发完才开始 `answer_start` / `answer_delta`。
+   * 所以 `answer_start` 这一刻卡片集合就是完整的 —— 在这里发既不会「先发一半再补」,
+   * 又能在正文第一个字之前就把卡片摆好。
+   *
+   * 不这么做的话(旧行为):卡片要等 done 才发,与已经流出来的正文顺序冲突,
+   * 只能靠 `answerStreamCompose` **整块重写**把卡片插到正文之上 ——
+   * 用户看到的就是「正文先渲一遍,然后从头再渲一遍」,第一次纯属浪费(2026-09-21 用户指出)。
+   *
+   * 无卡片(闲聊 / 纯知识问答)时什么都不发;开流后才到的 item 由 `flushContent` 兜底。
+   */
+  private emitCardsBeforeStream(): void {
+    const cards = buildPolicyTableContent(this.pendingItems, this.pendingSources);
+    if (!cards) {
+      return;
+    }
+    this.preStreamLeading = cards;
+    this._eventbus.emit('message', cards);
+  }
+
   /**
    * 撤去本轮尚未定稿的流式正文(后端 answer_abort / 出错 / 断流)。
    *
@@ -1269,6 +1323,9 @@ export class ApiChatCoordinator {
         this.answerStream = state;
 
         if (effect.kind === 'start') {
+          // F079 卡片先于正文:正文马上要逐片上屏,卡片必须**抢在它前面**发,
+          // 否则只能等 done 时整块重写把卡片插上去——用户会看到正文先渲一遍、再从头渲一遍
+          this.emitCardsBeforeStream();
           // 正文即将到达:此后 progress/heartbeat 不再发 scope(见上面那个 case 的守卫)
           this._eventbus.emit('answerStreamStart', {
             streamId: effect.streamId,

+ 2 - 1
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -407,7 +407,8 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
         before,
         body,
         String(payload?.leading || ''),
-        String(payload?.trailing || '')
+        String(payload?.trailing || ''),
+        String(payload?.replacedLeading || '')
       );
       if (!matched) {
         console.warn('[answer-stream] 上屏内容末尾不是流式正文,重排走兜底(不重复、不丢字)');

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません