Releases: cv-superding/dsh-deepseek-web-login
Release list
v0.6.38
工具调用协议从裸 JSON 改成围栏包裹(```dsh-tool),根治"标记漏到正文"这一类问题。
起因:用户拿 cuckoo 的分享页来对比
用户问「为什么 cuckoo 不会出现 DSML 等内容,他不也是工具调用」。取它的分享页逐项统计:
| cuckoo | 我们(改之前) | |
|---|---|---|
| 调用格式 | ```cuckoo 围栏代码块 |
{"tool_calls":[…]} 裸 JSON |
页面里 DSML |
0 | 出现过 |
页面里 tool_calls |
0 | 出现过 |
页面里 invoke / parameters |
0 | 出现过 |
根因就在格式本身:围栏自带语法边界,模型即使在围栏外多写一句废话也进不了正文;
裸 JSON 没有边界 —— 模型在思考里写 JSON、或者在 JSON 前后多写一个字,整段直接成为正文。
这也是为什么我们的规则 6 早就写着"禁止 XML 标记"却仍然漏:禁令能约束内容,约束不了边界。
DSH 那侧不用改:我们把调用转成 tool-call 事件交给它,它只看事件、不看文本。
改动
提示词(两份,TOOL_PROTOCOL_INSTRUCTIONS 与串行版必须逐字一致):
- 示例与开头句改成
```dsh-tool包裹 - rule 6 改为「必须用围栏包裹;不包裹的 JSON 与 XML 标记都会泄漏」
解析器(protocol.ts):
stripCallFence():调用块前后无条件剥掉所有围栏(含 ```json 这类模型自选语言名)。
进入该函数的文本都已确定属于调用块,所以放宽是安全的。CALL_FENCE_OPEN_RE+sawCallFence:在drain()里对普通正文判围栏。
⚠️ 只认带dsh-tool的开栏,且不加"后面不是闭栏"的负向断言 ——
逐字符分块时开栏先到(JSON 还没来),那条断言会把它误判成闭栏而放行出去。- 闭栏只在本流见过开栏时才剥(
sawCallFence门控)。
没有动:extractBalancedJson 要求 { 在开头这件事(靠捕获时先剥 head 解决)、
DSH 侧的调用解析、裸 JSON 的支持路径(模型不听话时的兜底必须留着)。
期间踩到的两个坑
① 放宽判据吃掉正常内容(真实回归)。 一度无条件剥所有 ⇒ `check-auto-continue` N03 (正文里的xml 示例)与 check-tools-section 各变红。曾试图用"独占一行的裸闭栏"补救,
逐字符分块时闭栏还没成行、照样漏 user 的代码块。最终形态:普通正文只认带 dsh-tool 的开栏,
闭栏靠 sawCallFence 配对。
② 围栏示例有体积成本。 maxChars=5000 的极小预算下,协议头占 3304/5000(66%),
新增的围栏示例把工具定义挤掉了(check-tools-section 变红)。压掉 rule 6 与 rule 8 的冗余措辞后
协议头 2869 字符(比改动前只多 55),工具定义恢复。logic-test 里加了一条上限断言
(< 3000)防它悄悄涨回去。
验证
logic-test62 → 67 项(5 条新围栏用例:整块/逐字符/按行三种分块 × 带散文、
正常代码块不许被剥、裸 JSON 兜底仍可用、协议指令必须要求围栏 + 体积上限)dev/probe-fenced-protocol.mjs:7 种形态全部"调用提取成功 + 零泄漏"- DSML 矩阵 144/144 仍全拦得住
- 四处变异手工确认打红(脚本里的
execFileSync在本机不可靠,变异写不进去 ⇒ 假"全绿"):
开栏不扣留 → 2 红、flush 不剥闭栏 → 2 红、head 不剥围栏 → 1 红、stripCallFence恒等 → 2 红 - 又抓到一次虚守卫:提示词退回裸 JSON 时用例仍全绿(只查了
includes('dsh-tool'),
而围栏示例行里也有这个词)。补上"必须出现inside a fenced code block"后变异正确打红。 tsc/ smoke / 产物 / 64/64 全过,真实留痕未污染
v0.6.37
修 DSML 标记泄漏:dsml- 连字符变体带空格时整块原样上屏(用户分享页现场)。
现场
用户对比 cuckoo(干净)与本插件的分享页,报告 DSML 漏到网页端。分享页原文:
<||DSML|| calls>
<||DSML|| invoke name="pwsh">
<||DSML|| parameter name="command" string="true">Get-Date -Format …</||DSML|| parameter>
</||DSML|| invoke>
</||DSML|| calls>
同时工具调用确实执行了(下一轮有 [Tool Result for call_68d163…])—— 同一段标记
既被执行、又漏到正文,所以不是"认不出",是"没走进捕获态"。
根因
DSML_PREFIX(竖线段,可选)与 (?:dsml-)?(连字符段,可选)是两个各自独立的可选片段。
<dsml- calls>(连字符后带一个空格)两边都匹配不上 ⇒ 不进捕获态 ⇒ 整块当正文。
144 组合矩阵(前缀 6 种 × 形态 4 种 × 分块 3 种 × 通道 2 条)实测:修复前 54 个组合泄漏,
其中 dsml- 两个变体全部 24 个组合整块上屏,正文与思考通道都一样。
为什么一直没暴露
现有用例只写了无空格形态(check-dsml-stray.mjs / logic-test.mjs 都是 <dsml-calls>)——
测的形态和现场形态不是同一个。与「0.6.28 思考分支一句 continue 跳过全部四道网」同类:
判据只覆盖了一种规范写法,现场是另一种。
修法
- 前缀两种变体合并成一个可选组
DSML_PREFIX_BODY,dsml-后面允许空白。 normalizeDsml的两个dsml-替换同样吃空白(否则归一化后仍剩< calls>,后面全失败)。XML_MARKER_STARTERS补dsml- tool_calls/dsml- invoke/dsml- calls
(hold-back 判据是逐字符比对 starter 前缀,少列一种跨包就判不出)。findXmlToolCallEnd里那条闭合正则只认半角单竖线(?:\|\s*DSML\s*\|\s*)?,
而现场是全角||⇒ 裸 invoke 块的收尾会落到"当正文吐出"分支。改用共用片段。stripStrayToolMarkup的包裹标签前缀改为可选(裸<calls>/</calls>此前只剥闭合,
开启标签整行残留)。
踩到并修掉的第二个坑:可选性不能写在片段里
修完第一处,logic-test 立刻红:在 HTML 里 <invoke> 不是一个标准标签 被吞。
原因是我把"前缀可选"实现成 (?:${DSML_PREFIX})?${DSML_HYPHEN} —— 两个各自可选的片段串起来,
整组仍可为空 ⇒ "无前缀也匹配 invoke" ⇒ 正文里正常讨论的标签被吞。连踩两次才想清楚:
可选性只能写在整个片段的外层,两个变体必须放进同一个交替里。
所以引入了 DSML_PREFIX_ONLY(不带 ?)专供 invoke 使用:包裹标签是 DSML 私有标记,
剥它零风险;invoke 是通用 XML 标签,正文里真的会被讨论,必须要求带前缀。
用例
check-dsml-stray18 → 40 项:前缀矩阵(7 种前缀 × 配平/截断/逐字符)+ 一条反向用例
(正文里正常讨论<invoke>不被误吞)。dev/probe-dsml-matrix.mjs:144 组合探针,判为泄漏时打印上屏原文。
⚠️ 写探针时我连栽两次假结论(把ReasoningSanitizer.push的字符串返回值当对象用;
模板拼接出错造出双份后缀),两次都是"只报结论不打证据"。探针必须能自证。- 变异验证:①去掉
dsml-的空白容忍 → 7 条红;②invoke前缀改回可选 → 新用例 +
logic-test各 1 条红。两个方向都有守卫。 - 产物断言修了一条:原正则绑死了旧的双可选结构(正是本次修掉的错误结构),改成守意图。
tsc / smoke / 产物 / 64/64 全过,矩阵 144 组合全拦得住,真实留痕未污染。
v0.6.36
把"提示词里到底有什么"变成可查的事实(诊断),并顺带给出这次现场的完整核对结论。
用户反复问「是不是把答案直接告诉模型了」—— 这个问题只靠字符数回答不了:一个 25k 的提示词
既可能是"把模型的旧回答又发了一遍"(缺陷),也可能是"工具返回"(正常且必要)。
两者在网页端看起来一模一样,但一个是 bug、另一个是机制。所以每轮额外落盘结构计数:
"stats": { "assistant": 0, "user": 1, "toolResult": 1, "systemBlocks": 0 }
assistant > 0 才是"把模型自己说过的话又发了一遍"。以后不用读分享页估,直接查这个文件。
本次现场的核对结论(dev/replay-turn-prompts.mjs 新增,用会话日志重放真实发出去的提示词):
| 轮次 | 实发字符 | 里面有什么 |
|---|---|---|
| "现在几点了" | 11 | 只有 User: 现在几点了 |
| 下一轮 | 12 | 只有新增那一条 |
| 天气那轮的搜索返回 | 25491 | [Tool Result for call_…](工具返回,必须回灌) |
真实发送里 Assistant: 是 0 条 —— 模型的旧回答没有被发回去。
分享页里能看到的 [Tool Result for call_055551695bfc4f82b3e9] 2026-10-02 19:34:10 星期五
正是 0.6.35 生效的证据(改之前这里会是 User: 2026-10-02 19:34:10 星期五);
Assistant: 前缀 0 次、Tool Calling Protocol 只出现 1 次(没有重复的固定头)。
⚠️ 说明一件事,免得再被误读:"网页端的提示词里出现了时间/天气"本身不是 bug ——
那是 agent 自己调了Get-Date/web_fetch,工具输出必须回灌,否则模型无从得知。
判断标准只有一条:它是[Tool Result for …](正常)还是User: …(缺陷)。
新增 dev/replay-turn-prompts.mjs:把某会话每一轮真实发出去的提示词重放出来并给出结构统计。
以后再遇到"提示词里有什么"的疑问,一条命令即可,不必依赖分享页的二手转述。
v0.6.35
纠正 0.6.34:真正的原因是 DSH 的工具返回是 role: "tool",而我们没有这个分支。
0.6.34 我按"工具返回被折进一条普通 user 文本"去修的(判据是"assistant 发过工具调用 ⇒ 紧随的
user 文本就是返回")。那个推断是猜的,而且我的证据本身是错的 —— 我用的
dev/dump-turn-input.mjs 只打了 user/message / assistant/message / request/header / step/end
四种事件,而工具返回记在第五种里:
type=tool/result {"message":{"role":"tool",
"toolCallId":"call_c2cf288c12734112b11e",
"content":[{"type":"text","text":"2026-10-02 18:54:28 星期五\r\n"}],"isError":false}}
role: "tool" —— 不是 role:'user',也不是 tool-result 块(测试夹具里长的是后者,真机不是)。
我们原来没有 tool 这个分支,于是它掉进 user 分支 ⇒ 渲染成
User: 2026-10-02 18:54:28 星期五 ⇒ 模型以为"用户告诉了我时间"(它的思考原话:
「我没拿到工具结果,用户直接给了时间。那就接受。」)⇒ 回答变成「收到,…」。
改法(这次是有证据的):serializePromptParts 增加 role === 'tool' 分支,
用消息自带的 toolCallId 与 isError 标成 [Tool Result for <id>],
并清掉 0.6.34 那条"预期下一个 user 文本是工具返回"的待办。
0.6.34 的推断保留为次要防线(注释已改写,明确它不是主要依据),因为两种形状的期望输出一致、
留着能覆盖我没能证伪的那种形状;且主分支会先清掉待办,它不会再误触发。
顺带修掉那个把我带偏的工具:dev/dump-turn-input.mjs 增加 tool/call / tool/result 分支 ——
看不见工具返回的诊断,比没有诊断更危险(它让我在错误前提上写了一版修复)。
用例 69 项(新增 4 条,直接用手机会话日志里的确切形状)。
toolCallId 写成同一个值,
于是关掉主分支时次要防线给出同样结果、用例照样全绿(变异只红 2 条而不是 4 条)。
把两边的 id 改成不同值后,变异才真正红 4 条 —— 又一次"两个来源同一个值就测不出真伪"。
v0.6.34
修复:工具返回被当成「用户说的话」发给模型 —— 所以它回「收到,…」而不是回答问题。
用户现场(原话):「提示词还是把答案直接告诉模型了,难怪最后模型的回答有『收到,…』」
(附分享页 + 截图:问「你知道现在几点嘛」→ 答「收到,2026-10-02 星期五 18:54,傍晚快七点了。」)
先排除掉已修的那一半:0.6.33 确实在跑(日志已有 firstDiff),而且增量已经正常 ——
18:51:37 chars=17 / 18:54:09 chars=11 / 18:54:26 chars=14 / 18:54:30 chars=31,
对比修之前的 40193。所以这次的「答案在提示词里」不是回声。
证据链(dev/dump-turn-input.mjs 读 DSH 自己的会话日志 + 分享页逐行核对):
turn 3 step 1: user 「你知道现在几点嘛」
assistant 「pwsh」 ← 工具调用
step 2: assistant 「收到,2026-10-02 星期五 18:54,傍晚快七点了。」
中间那条工具返回,在 DSH 的消息列表里根本不存在(不是 tool-result 块)。
而分享页里渲染出来的是 User: 2026-10-02 18:54:28 星期五 ——
User: 前缀只可能由我们的"纯文本分支"产生 ⇒ DSH 把 pwsh 的输出当成一条普通 user 文本交给我们。
于是模型读到的是"用户告诉了我时间"。它自己的思考原话(分享页里看得到):
「用户告诉我时间。不需要工具。」
「不过我应该核对一下——我没拿到工具结果,用户直接给了时间。那就接受。」
⇒ 回答从"回答几点"变成"收到,…"。用户看到的"答案被喂进去"是真的,但它的根因不是重发,是错标。
修法:serializePromptParts 认出这种形状 —— assistant 发过工具调用 ⇒ 紧随其后的那条 user 文本
按构造只能是工具返回(agent 循环里必须先有工具返回才轮到下一条用户输入),于是标成
[Tool Result for <id>],不再渲染成 User: …。
一对多时逐个配对(一个调用一条返回);数量对不齐就合并成一条(宁可少一层对应关系,
也不能让它看起来像用户说的话);带图片的 user 消息不误标(那是用户真的发的)。
新增 5 条用例(check-context-feed 65 项):必须标成工具返回 / 没有工具调用时不许误标 /
多调用多返回逐个配对 / 数量不齐时合并 / 带图消息不误标。一处变异确认变红(关掉这条判据 → 3 红)。
v0.6.33
修复:断链重发时把模型自己刚说的话又喂了回去 —— 一句话走掉 4 万字符。
用户现场(原话):「我就说了个『哇哦帅气』,结果网页版发送的提示词怎么这么长???
以及我问他天气怎么样,结果我看提示词,相当于我发给网页版已知的答案,然后模型在回复我!」
日志一条就够(feed-decisions.jsonl):
18:33:09 not-appended chain=15 ent=15 tail=false chars=40193 head=14594
5 个字的输入,发出去 40193 字符。 网页端的用户气泡里赫然是"上一句天气回答 + 哇哦帅气"。
根因:replay(链还在、但不能只发增量)发的是整份历史,而历史里全是 Assistant: … 条目
—— 也就是模型自己刚说过的那段回答。增量路径早就有"剔掉模型回声"这条规则(0.6.17 为同一个
投诉加的,原话也是"完全没必要"),但 replay 这条路没走它 —— 当时注释写的是"只在增量里剔,
全量需要完整对话(从零重述)"。这个理由在 replay 上不成立:走到 replay 说明链还在
(同会话、同账号),那条会话里固定头和历史都还在,根本不是"从零重述"。
改法:replay 按代价从小到大挑,第一个可用的就用:
- 从第一个分歧点起的条目(剔回声)—— 通常就是这一句新消息;
- 整份条目(剔回声)—— 分歧点算不出来时退一步;
transcript(0.6.32:至少省掉固定头);- 整份
full—— 什么都算不出来时的兜底。
每档都要求"非空且不超预算";头变了时只有第 4 档合法(新头从没发过)。
上方那次的 40193 字符,在新逻辑下是 User: 哇哦帅气。
顺带把"为什么断链"变成一个可读的事实:以前只有 tailSame=false,它只说"链尾变了",
不说是哪一条、从哪儿变的 —— 于是每次排查都只能猜。现在每轮多记 firstDiff
(和链从第几个条目开始不一样)。这一轮假如早就有它,一眼就能看到是哪一条被改了。
用例:check-context-feed 60 项(新增 7 条:最小档 / 回声必须剔 / 头变了必须整份 /
退到整份剔回声 / 不许发空串 / 四档都不行时兜底 / 历史变短也走最小档),
check-context-chain 19 项(端到端断言"只发分歧点之后的条目、且不许含固定头",
并守 firstDiff / promptChars / headChars 三个留痕字段)。
两处变异确认变红:把 replay 改回总是发 full(9 红)、去掉回声过滤(4 红)。
顺带修的:三条绑语法形态的产物断言与两条老用例,在行为变更后失配 —— 按"改测同一意图"
重写(例如"历史被改写 ⇒ 退回全量"改成"⇒ 只发分歧点之后的条目"),没有把行为改回去。
v0.6.32
修复:链式投喂"退回全量重发"时会重发整份固定头(约 6.35 万字符)—— 那一段是纯重复。
起因是用户对比了 cuckoo 与自己的分享链接:同一段 Tool Calling Protocol 在网页端出现了两次。
先看数据,不猜(feed-decisions.jsonl,25 条真实记录):
| reason | 次数 | 含义 |
|---|---|---|
chained(增量) |
14(56%) | 正常 |
new-session |
6(24%) | 新窗口的第一句 —— 新会话必须全量,天经地义 |
no-parts |
4(16%) | 内部标题请求 |
not-appended(链断) |
1(4%) | 只有这一次真的退了全量 |
所以"每次调用工具就重发"与日志不符(1/25)。但那次重发里有一件确实该改的事:
根因:replay("链还在但不能只发增量")一律发整份 full = 固定头 + 历史。
而走到 replay 就说明链还在(同一个网页端会话、同一个账号)——
那条会话的首条消息里已经把固定头给过了,重发它是纯重复。
固定头 = system + 协议指令 + 工具目录,实测约 6.35 万字符,占一轮的大头;
网页端看到"同一大段又出现一次"就是它。
改法:PromptParts 新增 transcript(full 里属于历史的那一段,超预算时是截断后那份),
replay 在头没变时只发 transcript:
not-appended(头已确认相同)⇒ 省掉固定头,省下的就是那 6.35 万字符。head-changed⇒ 必须整份重发(新头从没发过,省了模型手里就是旧头)。- 没传
transcript⇒ 退回旧行为。所以这是一条纯优化:漏传只少省一点,不会错。 ⚠️ transcript交出去的必须是截断后那份 —— 否则"只发历史"会比原来的全量还长,把优化做成事故。有用例钉死。
顺带补上体量诊断:feed-decisions.jsonl 此前只有"为什么走全量",没有"发了多大"。
正是这个缺口让我今天只能去读分享页估字符数 —— 估出来的数字不能用来下任何结论。
现在每轮多记 promptChars(实发字符数)与 headChars(固定头字符数),体量从此可测。
新增 6 条用例:省头路径(两种触发都要省)/ head-changed 必须重发头 / 没传 transcript 退回旧行为 /
transcript 恒等于 full 里属于历史的那一段 / 截断时 transcript 也必须是截断后那份。
三处变异确认变红:把 replay 改回总是发 full(2 红)、把 transcript 交成未截断那份(1 红)、
以及重建后的产物断言。
⚠️ 顺带修掉一条绑语法形态的产物断言:它写死const replay = (reason)+ 120 字窗口,
于是这次无害的形态改动(箭头+对象字面量 → 块体)把它打红了。已放宽窗口并注明理由 ——
产物断言要守意图,别守签名长什么样。
v0.6.31
修复:新建一个窗口、只发一句话,网页端会多出一个会话(那个是"生成标题"用的脚手架)。
现场(用户原话):「刚在 dsh 中新建一个窗口聊天,就发了一句话,网页版直接俩窗口」。
feed-decisions.jsonl 里两条对得整整齐齐:
12:55:54 new-session sess=04c63135 ← 对话那条
12:56:00 no-parts sess=de6a8d4f ← 网页端多出来的那个
de6a8d4f 正是用户在网页端看到的那条(标题请求的 prompt 直接显示在里面)。
根因:session-title 这类内部请求必须有一条自己的网页端会话才能调 completion
(0.6.26 为了修「n/n 分叉」特意把它们和对话分开,见 requestSlotKey 的注释)。
当时的注释写的是「代价是内部请求自己占一个会话,它不出现在对话里」——
判断漏了一半:它确实不在对话里,但它出现在侧边栏里。
而它从来不会被删:用户设的是 sessionCleanup: keep,而清理器的 schedule() 在 keep 下
第一行就 return —— 于是这条会话连 ledger 都没有(从没安排过删除),永远留在网页端。
改法:把「内部请求的会话」与「用户的对话会话」在收尾时彻底分开。
- 收尾时按
promptParts有没有传来分(与requestSlotKey、adapter 的chatLike同一处判据):
不带 = 内部请求 ⇒ 会话是脚手架,一律走新的onDiscardSession通道丢掉,
不进用户的清理队列。 ⚠️ 复用来的会话也要丢 —— 它不在收尾逻辑的owned集合里(那一轮没调createSession),
这正是最容易漏掉的一半。- 清理器新增
discard(auth, sessionId):不受keep/manualOnly影响 ——
那两道开关的语义是「别删我的对话」,而脚手架会话不是用户的对话。 ⚠️ discard只删那一个,不 drain 队列:manualOnly(链式模式)下队列里攒的是
用户的会话,借这次机会顺手删掉就变成"用户没点按钮却被删了"(0.6.1x 修过的那个 bug)。⚠️ 仍然受deleteWebSessions === false("一个都不许删"总闸)约束 —— 那个开关不能破。
所以关掉总闸时这类会话仍会留在网页端,这是已知代价。
顺带把一条"注意事项"升级成硬约束:测试 fixture 模拟真实 chat 时必须传 promptParts。
改了收尾判据之后,4 个用例文件成批变红 —— 全是拿裸 params(不带 promptParts)去测
"chat 会话的轮换 / 回收 / 归属"的。这不是 bug,是夹具没按生产链路的形态构造
(adapter 只给 chatLike 传 promptParts)。已给 6 处夹具补上,并在文件里写清为什么。
新增 6 条用例(check-internal-session-discard.mjs):内部请求走「丢掉」通道且不进用户队列 /
复用的会话也要丢 / 对话请求不走丢弃通道(防过度清理)/ keep 与 manualOnly 下都必须删 /
discard 不许顺手清空用户的队列。三处变异确认变红:关掉内部请求分支(2 红)、
把 discard 改成 drain 队列(2 红)、以及重建后的产物断言。
⚠️ 又记一条产物断言的坑:打包器除了会去掉单语句的花括号,还会把undefined改写成void 0
—— 断言里写=== undefined必然假红(这次踩了)。
v0.6.30
补齐请求头:把浏览器自动加的那批指纹头收回来,并修掉一个过时的写死版本号。
起因是调研"能不能不打开浏览器也像真浏览器发消息"(报告见工作区 浏览器指纹伪装可行性调研.md)。
调研的结论之一是:在改 TLS 之前,头部这一层就有零成本的差距可以补。
对着真实登录捕获(accounts/acc_9be659e4.json,2026-10-02T03:42Z)核对出两处:
-
收头的规则只认
x-*,于是浏览器自动加的那批全被丢掉:
sec-ch-ua/sec-ch-ua-mobile/sec-ch-ua-platform、
sec-fetch-dest/sec-fetch-mode/sec-fetch-site、priority、accept。
这批是"是不是真浏览器"最表层的信号 —— 真实 Chrome/Edge 发 fetch 请求时一定会带,
而我们一条都没发。现在收进来,并且保留浏览器给头的顺序(顺序本身就是指纹)。 -
x-client-version写死2.0.0,而真实捕获是2.5.0—— 落后两个小版本,而且
没有任何机制会发现它过时。真实值一直走的是抓取那条路(所以线上没暴露),
但抓取失败时就会发这个陈旧值。现在:抓来的真值永远优先,写死的只做兜底并标注要定期校准。
顺带(同一批证据的两件事):
pickExtraHeaders的规则原先在browser-login.ts(CDP 路径)和login.ts(旧 webRequest 路径)
各写了一遍 —— 两份一旦漂移,"同一账号换个登录方式指纹就不一样",且不会有任何报错。
现在只有一处,两边都调它。captureDefect()增加一条判据:抓到了头、但里面没有x-device-id也算捕获缺陷。
依据:真实捕获里x-device-id是数美设备指纹(UUID),而上游对"缺少浏览器设备指纹"
会直接返回biz_code=11 / RISK_DEVICE_DETECTED—— 缺了它不是"信息少一点",是会被风控判定。
⚠️ 只覆盖"有头但缺这一项";extraHeaders整个为空的手工粘 token 路径故意不碰(既有设计)。
刻意不做的一件事:不收 accept-encoding。
它是浏览器的解压能力声明,服务端可能据此回 zstd,而 Node 侧的 fetch(undici)不保证能解 ——
一旦解不开,SSE 会静默变成乱码(不是报错,是内容全错)。这是"宁可少一个头,也不要静默坏掉"的取舍,
代码里写了理由,用例里也钉住了(防止有人"顺手补上")。
x-app-version(7 个 x-* 里没有它),我们仍在发。
保留是为了不改变既有行为,代码里标了注释 —— 想更贴近浏览器,删掉那一行即可。
新增 11 条用例(check-request-headers.mjs,此前 pickExtraHeaders 只有 1 条、buildDsHeaders 零覆盖):
该收的收进来 / 不该收的别收(逐请求头 + accept-encoding)/ 顺序保留 / 抓来的真值赢过兜底 /
逐请求头必须用当前值(旧 token 旧 cookie 不许漏出去)。
四处变异确认变红:白名单改回 x-(3 条红)、兜底覆盖抓取值(1 条红)、关掉 x-device-id 判据(1 条红)、
以及重建后的一处产物断言。
⚠️ 写用例时踩了一次虚断言:第一版把"抓来的值"和兜底都写成2.5.0,于是
"兜底覆盖抓取值"这个变异体照样通过(两者无法区分)。改用88.8.8之后才真的能打红。
这正是"先自问这条用例在改之前是不是也绿"那条铁律的又一次实证。
v0.6.29
修复:登录态过期(或账号被限流)之后,重新登录会"又新建一个网页端会话"、整段历史全量重发。
现场:用户在旧窗口发了一条消息,网页端凭空多出一个会话;DSH 日志里那一轮是
API 密钥无效(AUTH)→ 重试 1795ms。他随后重新登录了同一个账号,但会话没能接回去。
feed-decisions.jsonl 对上了:那次是 new-session,而 account 前后完全一样 ——
账号没变,是会话槽被退役了。
根因:retireSession() 被当成"失败即弃"用在了三处失败分支(webapi.ts):
建连抛错、!resp.ok、业务错误信封。401/403 说的是令牌过期、429 说的是账号级限流
—— 这些跟"这个会话"毫无关系,而且换一个新会话账号照样被限:退役它没有任何好处,
唯一的效果是把用户整段对话丢掉(重登后只能新建会话 + 把历史全量重发一遍)。
改法:只有服务端明说"这个会话废了"(业务码 invalid chat session id)才退役;
认证与限流这类账号级失败改为记进 keepIds,由收尾逻辑保住。
- 判定发生在
openCompletion(只有它看得见错误码/业务码),消费发生在
streamWebCompletion的 finally —— 因为建连阶段失败时外层sessionId还是 undefined
(它在await之后才赋值),id === sessionId恒假,靠错误码在外层判会连"本轮真正在用的
那个会话"一起退役。 ⚠️ 5xx / 网络失败 / 取消 / 空响应不在保留之列:那些情况下服务端可能已经开始生成、
会话停在半路,照旧退役(N04 的意图不削弱)。原「失败即弃」用例改用 500 并改名为
「服务端 5xx(会话可能停在半路)⇒ 不复用那个会话」,意图不变、范围收窄。
新增 5 条用例:401 / 429 / user is muted / throttled 四种账号级失败后必须接回原会话、
且不得把会话交给宿主去删;外加"新窗口第一轮就 401"(走的是收尾逻辑那条路径 ——
复用来的会话不在 owned 里,两条路径必须各守一条)。
四处变异全部确认变红:内联跳过、信封分支、收尾闸门,以及重建后的产物断言。