Skip to content

fix(chat): repair unanswerable message tails instead of rejecting them - #274

Open
Smith-106 wants to merge 2 commits into
dwgx:masterfrom
Smith-106:fix/repair-unanswerable-message-tails
Open

Smith-106 wants to merge 2 commits into
dwgx:masterfrom
Smith-106:fix/repair-unanswerable-message-tails

Conversation

@Smith-106

Copy link
Copy Markdown

改了什么 / What changed

之前:会话历史落在三种「上游无法作答」的形状上时请求被拒 —— 以 assistant 轮结尾(Anthropic prefill 形状)、或最后一条 user 内容为空 → 本地 400;带一条孤儿 tool 结果 → 上游 invalid_argument → 502。

现在:这三种尾巴在校验/编码前被规范化(裁剪到最后一条可应答轮 / 丢弃孤儿结果 / 丢弃空 user 轮),请求照常作答;真正没有可应答轮的历史仍然返回原来的 400([]、[system]、[assistant]、[system, user:""])。

为什么 / Why

逐条实测(本仓库 /v1/chat/completions,非流式)—— 三条形状都是我亲自复现的,不是外部清单转述:

形状 之前 现在
[user, assistant](尾部 assistant) 400 The conversation must end with a user message or a tool result. 200
[user, assistant, user:""] 400 The last user message has empty content. Provide a non-empty user prompt. 200
[user, {tool, tool_call_id 未被声明}] 502(上游 invalid_argument) 200
[assistant] / [user:""] / [system, user:""] 400 400(保留)
合法 [user, assistant(tool_calls), tool] 200 200(不误伤)
32 轮长历史 + 尾部 assistant 400 200

裁剪只发生在「最后一条可应答轮之后」,system 轮原位保留,不发明任何内容;无可应答轮时原样返回,仍由既有 400 负责 —— 该契约的断言保持原样,只把「尾部 assistant」从不可修复列表移到新增的可修复列表(3 条行为断言,断言 HTTP 结果而非源码文本)。

同类先例:#270(devin-connect 同角色文本轮合并)—— 同一类「线编码前的形状修复」,落在共享校验路径上,因此 /v1/chat/completions、/v1/messages、/v1/responses 一并覆盖。

测试 / Testing

Linux + Node 24(与 CI 同环境)、干净 clone、npm ci;退出码直接取自命令本身(未经管道):

node scripts/spec-static-check.mjs                                           exit=0   # specs: 68  mutations: 678  anchors unique
node scripts/secret-scan.mjs                                                 exit=0
node --import ./test/setup-env.mjs --test test/responses-chain-scope.test.js  exit=0   # tests 17  pass 17  fail 0
WIRE_BASE_TREE=<上游 HEAD 检出> node --import ./test/setup-env.mjs --test test/wire-byte-identity.test.js   exit=0   # pass 1  fail 0

npm run test:release(= run-test-shard.mjs 0 1,366 个测试文件)改动前后各跑一次(A/B):

树 pass fail skip 失败项
上游 HEAD(未打补丁) 4665 2 1 langserver-invariants / langserver-redact(90s 超时)、restart(exit 1)
本 PR(已打补丁) 4667 2 1 同上,逐条相同

两者失败集合完全一致(均为本机 scratch 环境的 langserver / 进程类测试依赖所致),0 净新增失败;+2 pass 正是本 PR 新增的用例。CI 的 4 分片为权威口径。

wire-byte-identity 不指定 WIRE_BASE_TREE 时按设计跳过;指定上游检出后为 pass 1 / fail 0 —— 默认请求路径的完整字节与改动前完全一致。

Checklist

  • 代码风格与现有文件一致(2 空格 / 单引号 / 分号)
  • 没有引入 npm 运行时依赖(零依赖不变)
  • 测试跑过了,结果贴在上面
  • 新行为有测试,且断言行为而不是 grep 源码文本
  • 没有新增开关(无需登记默认开/关台账)
  • 未改动 wire 字段号
  • 未涉及 dashboard UI
  • commit message 无任何 AI 署名尾注

- src/handlers/chat.js: repairUnanswerableMessages drops the three tails the
  upstream cannot serve — a trailing assistant turn (the Anthropic prefill
  shape), an empty-content user turn, and a tool/function result whose
  tool_call_id no earlier assistant turn declared — and trims a trailing
  assistant run back to the last answerable turn. A chain with nothing
  answerable left is returned unchanged, so the explicit 400 still owns it.
- test/responses-chain-scope.test.js: the trailing-assistant shape moves from
  the unanswerable list to a new repairable list; the rejection assertions for
  genuinely unanswerable chains are unchanged.
@Smith-106

Copy link
Copy Markdown
Author

串行补充(已清场、无并发):npm run test:release 单独重跑结果不变 —— 4667 pass / 2 fail / 1 skip,且这 3 项(langserver-invariants、langserver-redact 各 90s 超时;restart exit 1)在上游 HEAD 的对照跑中逐条相同,属本机 scratch 环境依赖,与本次改动无关。

@dwgx dwgx left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

评审:三种结尾你自己打出来了,现象是真的。这版先别合。它做了两件仓库原来故意不做的事:把「只在原生工具那条路上才删」的工具返回提前整段删掉;把助手已经写出的半句丢掉,说成是接着写。

你这份说明我收下。中英都写了,三种形状是你本地打的,没有加依赖,也没有改协议字段号。

我实跑过什么

对着 head 69876a9、parent a454117,单独工作树里看的就是这一份补丁。

项 结果
test/responses-chain-scope.test.js 17 通过 / 0 失败
真实 handleChatCompletions,没有账号、外网拒绝 [user, assistant]:原版 400,这版 401 NO_TOKEN。不是说明里的 200
normalizeMessagesForCascade,stripOrphans=false 旧路径会留下被截断的那次工具返回;先过 repairUnanswerableMessages,这段内容没了

你写的现场 200,我这边没有原始回执,按你的报告记,不算我复现过。test:release 里原来就有的 2 个失败和 restart,原版上也有,不记成这个补丁的回归。

「不编新内容,只裁掉最后一条能回答的话之后」这句话我专门试过,不成立。

M1(必须改)— 找不到对应调用的工具返回,在选后端之前就被删了

repairUnanswerableMessages 会把整段 messages 扫一遍。工具返回的 tool_call_id 如果这次数组里没有对应的助手调用,就直接丢掉。

仓库里已经写明的规则在 src/handlers/tool-emulation.js:1290-1297:stripOrphans 默认是关的。客户端可能把更早一轮的工具调用截掉了,返回还在,这段话仍然有用。走文本模拟的时候,还要拿它去做内容清理。只有原生工具、上游要求这一轮自己对得上时,调用方才会显式传 stripOrphans:true。

你这刀在那条规则之前。后面即使选择保留,也找不回来。

我用的输入是这样:一条工具返回,它的调用在更早、已经被客户端截掉的轮次里,正文是 MARKER;后面再跟一条用户消息「总结上文」。

  • stripOrphans=false:旧路径留下 MARKER
  • 先过你这个函数:MARKER 没了
  • 这条工具返回在最后一条用户消息前面,所以也不是「只裁结尾」

请回到原来的边界。原生工具要删的继续删,文本模拟继续留。不要在校验前面再加一层意思不一样的删除。

M2(必须改)— 删掉结尾的助手消息,不是接着写

输入是:用户说「返回 JSON」,助手已经写出 {"approved": false, "reason": "。过了你的函数,只剩用户那句。approved=false 和写到一半的原因都没了。已经写完的助手回复,也会同样被丢掉。

原来返回 400 是故意的。responses-chain-scope.test.js 里你改掉的那段注释写着:上游不接受以助手消息结尾,本地直接拒绝,免得一次说不清的失败再记到账号上。

要改这条,先证明上游现在肯接着这段前缀写,或者明确写成「丢掉结尾,让模型重答」。不能把丢掉的内容说成「没有编新内容」。

M3(请补测试)— 新测试只证明「不是 400」

断言是 assert.notEqual(res.status, 400)。没有账号时,三条都会落到 401。找不到对应调用的那条,在原版上也不是 400,这条断言照样通过。

请把实际送出去的 messages、调用了几次、输入对象有没有被改,都钉住。至少留下这几种:文本模拟里被截断的工具上下文还在、成对的工具调用和返回不被误伤、助手已经写出的前缀还在。一旦有人把「找不到调用就删」改成一律删除,测试要失败。


wire-byte-identity 比的是默认请求的字节,不会跑到这次改的处理函数。它通过,不能算这三条已经覆盖。

这种结尾修复可以提。请对着仓库里已经写明的规则改。两条必须改的,可以分开提。改完 ping 我,我按你的新 head 再跑。

@dwgx

dwgx commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

谢谢你把三种现场自己先打出来,也谢谢你按我们的门槛走了「先跑、再改」。上一轮提的两点我逐条驱动过,都成立;这一轮又量了四条上一轮没量到的。结论不变:先别合,但改法很小,方向要换一下。

三件要改的

1. 「对不上调用的工具返回」被整条删了 —— 可仓库在文本模拟那条路上是特意留着它的。

tool-emulation.js:1290-1297 写着:删不删由调用方决定,默认不删;只有原生工具那条路会显式要求删。你这刀落在它前面,删掉就找不回来。实测:夹在中间的这种返回,旧路径留着,先过你这个函数就没了 —— 所以它不是「只裁结尾」,是整段清洗。

还有一处:你判断「有没有对应的调用」时只记前面走过的 id,于是调用排在结果后面的那种也会被删 —— [工具返回 → 调用 → 用户] 会变成 [调用 → 用户],返回没了、调用还在。tool-emulation.js:1106-1108 那行注释就是专为这件事写的。

这一支建议回到原位:原生工具该删的继续删,文本模拟继续留。

2. 结尾那条助手的半句被丢掉,然后回了一个「正常」的答案。

/v1/messages 上那是 prefill —— 客户端要的就是你接着写。现在 {"approved": false, "reason": " 整条消失,只剩用户那句,回 200。另外 [user:"上一问", user:""] 现在也不 400 了,回答的是上一问:客户端发了条空消息,拿回的是旧答案,没有任何信号。

要保留这个行为,就得把它写成契约(注释 + README 相应段落),并在测试里钉住真正发出去的 messages。不能写成「没有编内容」—— 问题不是编没编,是丢没丢。

3. 三条新用例里有一条在没打补丁的树上也是绿的。

尾部 assistant、尾部空 user 两条能区分;但「对不上调用的返回」那条断言的是「不是 400」,而那个形状在原版上本来就不返回 400,所以把这段逻辑整个删掉它照样绿。另外我把全部变异锚点提出来对了一遍,没有一个落在这一段,所以现在的绿说明不了机制被钉住。

我实跑过什么

head 69876a9(parent a454117)。主树没动:在临时目录里对着真的四个 handler 驱动,你分支的 chat.js 与我这边逐字节一致。本机账号池是空的,所以下表请读作 400 → 不再 400,不是「200 回执」:

输入 原版 本 PR
[user, assistant] 400 不再 400
[user, assistant, user:" "] 400 不再 400
[user:"更早的一问", user:" "] 400 不再 400,回答的是更早那一问
夹在中间的、对不上调用的工具返回 保留 整条删除
/v1/messages 结尾 assistant(prefill) 400 不再 400

我攻过、攻不动的:system 轮原位保留;输入数组没被改(18 个形状逐一比对,返回的是新数组);你放的位置是对的(在两条编码器之前,五条路由都过这一层)。

改完 ping 我,我按你的新 head 再跑一遍。

@dwgx

dwgx commented Oct 5, 2026

Copy link
Copy Markdown
Owner

补一句,免得你卡在一个本来就该我们决定的问题上。

上一轮我提的第二件里,有两个形状(请求以空 user 结尾、以助手半句结尾)到底该「拒绝 400」还是「照常回答」,是我这边的政策选择,不是你需要去设计的东西。你如果不想替我们决定,说一声,我把这份契约写下来(哪种形状我们服务、哪种我们继续拒),那你这边的活就只剩第一件(工具返回那一支)。

这不是催 —— 只是把它从「等你猜」变成「等我写」。推上来 ping 我。

… stripOrphans

Per review M1 on PR dwgx#274: the repairUnanswerableMessages pre-pass stripped
orphan tool/function results across the whole array (seen-so-far ids) before
stripOrphans could decide. That broke the documented opt-in rule
(tool-emulation.js:1290-1297: off by default, native path passes
stripOrphans:true at chat.js:3451, text emulation at 4425 passes nothing) and
reintroduced the seen-so-far trap the helper comment at 1106-1108 warns
about (a result whose call appears later was dropped while its call survived).

This change removes the shared-layer sweep entirely (repair function, call
site, and its tests), restoring base 400 behaviour for tails. Tail policy
(empty user / trailing assistant, review M2) is the maintainer's call and is
left for a separate contract, not decided here.

M3 regression pins added to test/tool-emulation-boundary.test.js: a result
whose call appears later survives, and a mid-array orphan survives the
text-emulation encoder.
@Smith-106

Copy link
Copy Markdown
Author

新 head 99ad55d 已推,按上一轮评审逐条说明。

M1(已改)—— 共享层孤儿清洗整段删
repairUnanswerableMessages 函数、chat.js:3037 调用点、responses-chain-scope.test.js 里 repairable 三条,全部拿掉。当前 src/handlers/chat.js 与 base a454117 逐字节一致(sha1 5900c0dc,对着你校验过的那份量的)。原生路径要删的继续由 stripOrphans:true(chat.js:3451)在编码器处删,文本模拟(4425 不传)继续留——回到仓库原来写明的位置。role:'function' 那半句也随整段删除一起没了,不再碰。

M2(本轮不动,等你写契约)
repair 整段删除后,尾部 assistant / 空 user 恢复原版 400,不再重答。你补的那句我收到了:这两个形状是服务还是拒,是你这边的政策,我不替你定。你把契约写下来(哪种服务、哪种继续拒)后,我对着它另提一支。

M3(M1 相关部分已钉住)
test/tool-emulation-boundary.test.js 的 stripOrphanedToolResults 块里加了两条,直接对着你驱动过的形状:

  • 调用排在结果后面([tool(c1) | assistant(c1) | user])结果保留——删掉 anywhere 匹配、改回 seen-so-far,这条红;
  • 中间孤儿([user | tool(gone):MARKER | user])过文本编码器(stripOrphans 不传)MARKER 还在——有人在共享层再加一刀,这条红。
    之前那条“在 base 上也绿”的孤儿用例已随 repairable 整段删除,不再留着充数。

本轮验证(干净 clone、无账号,与你同读法:400→不再400 那半句本轮不适用,repair 已删):

  • test/tool-emulation-boundary.test.js + test/responses-chain-scope.test.js: 38 pass / 0 fail(chain-scope 回到 base 的 15)
  • test/tool-emulation.test.js + dialect-neutralize + gpt-native:100 pass / 0 fail
  • node scripts/spec-static-check.mjs: exit 0(specs 68 / mutations 678);secret-scan: exit 0

ping 我时按 99ad55d 跑即可。

@dwgx

dwgx commented Oct 6, 2026

Copy link
Copy Markdown
Owner

新 head 收到,逐条回。M1 核过了,做得对。

独立复验(干净 clone、无账号、没碰你的分支):

  • src/handlers/chat.js 与 base a454117 逐字节一致(sha1 5900c0dc…,和你给的相同);
  • repairUnanswerableMessages、那个调用点、responses-chain-scope.test.js 里那三条 —— 全没了,该文件回到 base 的 15 条;
  • tool-emulation-boundary 从 21 条到 23 条,净增正好是你那两个守卫;
  • 顺带:你删掉的那条「在 base 上也绿」的用例,在当前 head 上量到的是 403 而不是 400 —— 确实在充数,删得对;
  • 门在你的树上 PASS:test:release 4607 pass / 0 fail / 78 skip。

M2 的契约直接写在这里,你不用等我另发文档(文档 + 测试正在落地,落地后这里补链接):

两条 400 都留。 理由不是口味,是实测:8334cea 当初把守卫挪进共享层时量到 —— 上游服务不了以助手结尾的对话,它回 UPSTREAM_INTERNAL,账号会被隔离。所以「服务这些形状」不是补功能,是让调用方账号被隔离的一条路。空 user 那条更早(79cd990 / fix #28)。⇒ M2 不需要任何代码改动,你这支的范围就是 M1 + M3。

M3 有一处要改:第二个守卫站错了层。 它直接调 normalizeMessagesForCascade(test/tool-emulation-boundary.test.js:241-255),可你要钉的那个回归(就是我 M1 说的共享层那一刀)在上游一层的 chat.js 里。

实测:把那一刀原样装回去(git checkout 69876a9 -- src/handlers/chat.js 盖在 99ad55d 上),这条测试照样绿(23/23),整个套件也不动(4607 pass);而直接探针显示那个变异确实还在丢形状。所以注释里那句「共享层永远不许预剥这个编码器要折叠的东西」,和帖子里说的「有人在共享层加一刀它就红」—— 现在都没被守住。更要紧的是:旧那条 repairable 用例删掉之后,整个套件里再没有任何测试能发现共享层又长出一把刀。

改法二选一,我建议第一个:

  1. 把形状从 handleChatCompletions 走文本模拟那条路打进去(responses-chain-scope.test.js 里有现成的 recorder 写法),断言孤儿内容确实到达折叠 —— 那才是你原本要钉的东西;
  2. 或者把注释和帖子里的说法缩到「只覆盖编码器内部那一刀」。

另外提一句:你的 base 是 a454117,那份 local-gate.mjs 比现在的 master 旧(还没有惰性声明那套)。合到 master 上会拿到新门 —— 我在评审里已经按四个路径声明替你跑过了(Windows 上真实惰性的是四个文件,不是两个)。

改完 ping 我,按新 head 跑。

@dwgx

dwgx commented Oct 7, 2026

Copy link
Copy Markdown
Owner

补一句我上一条承诺过的:契约已经落到 master 了。

  • 文档:docs/REQUEST-TAIL-CONTRACT.md
  • 测试:test/request-tail-contract.test.js(15 条)+ test/mutations/request-tail-contract.json(5 个变异)

里面写清了:两条 400 各自拦什么、为什么留(8334cea 实测上游服务不了以助手结尾的对话 → UPSTREAM_INTERNAL → 连续两次会让账号被隔离 120 秒;空 user 那条更早,79cd990 / fix #28)、在哪执行(chat.js:2994-3033,在流式/后端选择/账号获取/缓存之前,五条 API 面都过这里)、逐面的真相(含 Anthropic / Gemini 转换器会把无文本回合丢掉这条 —— 已记录,但不背书,将来要统一得另拿证据),以及改这条规则需要什么证据。

写这份文档的过程里还抓到两个真东西:

  1. /v1/responses 上空的 content: [] 原来拦不住:转换器把它变成字面字符串 "[]",「空」成了两个非空字节,于是溜过 400、把垃圾文本送给上游。已修(零长度数组保持为空)并钉住。
  2. 文档原本声称「三条空 user 属性全部有测试钉住」,实际有一条没钉 —— 把两个检查对调,一条测试都不会红。已补上顺序的测试。

所以 M2 现在是可执行的契约,不是一段话。你那边的活还是上一条说的那处(M1 的第二个守卫站错了层)。

nxtreaming pushed a commit to nxtreaming/WindsurfAPI that referenced this pull request Oct 7, 2026
Write down which conversation shapes the proxy serves and which it keeps
rejecting. The two local 400s in the shared chat guard stay: 8334cea moved
the check into the shared layer and measured that an assistant-terminated
conversation comes back UPSTREAM_INTERNAL with account penalties, and
79cd990 (fix dwgx#28) measured the empty-user shape as answered against the
system prompt.

State where the block is enforced (before streaming/backend/account/cache,
so no surface bypasses it), the per-surface difference (Anthropic and Gemini
converters drop textless turns before the guard) as documented-not-endorsed,
what is deliberately not promised (/v1/completions), and the measured
evidence bar for changing any of it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants