Skip to content

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

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

Smith-106 wants to merge 1 commit 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 再跑。

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