From b871c0c713ba11d99fb61c7915c257cfd920871b Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 16 Oct 2026 01:13:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(zcode):=20=E6=97=A5=E6=9C=9F=E5=9D=97?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E5=8F=82=E8=80=83=E7=9A=84=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E8=A7=84=E5=88=99=EF=BC=88=E5=B9=82=E7=AD=89=20+=20=E5=8F=AA?= =?UTF-8?q?=E7=9C=8B=E7=AC=AC=E4=B8=80=E6=9D=A1=EF=BC=89=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=20off-by-one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通读参考 zcode-identity.ts 的 withContextPrefix 后发现我的实现漏了两条官方行为, 且其中一条藏着真实 bug: ## ① ★ 幂等判断 off-by-one(真 bug) `""` 是 **17** 个字符,而我写的是 `c:sub(1, 16) == SR` —— 判断**永远为假** ⇒ 幂等完全失效:同一请求重试/多次 transform 时日期块 会叠加成多层,改变 content 的前缀结构(上游检查的正是这个结构)。 修法:用 `#SR` 算长度,不写死数字。 ## ② 只看**第一条**消息,且它必须 role == "user" 我原来实现成「往后扫第一个 user 消息」。官方行为是:只处理 `messages[0]`, 且若它不是 user 就**完全不动** —— 不是去找后面那条 user。已对齐。 ## ③ 其它类型兜底 参考对非字符串/非数组的 content 也会置为 `[prefix]`,照做。 判据 2 条(新增): - TestZcodeContextPrefixIsIdempotent 已含日期块时不重复插 - TestZcodeContextPrefixOnlyTouchesFirstMessage 首条非 user 时不动、首条 user 时插入 变异 2/2 咬住:把 #SR 改回写死的 16 → 报红;去掉幂等判断 → 报红。 ★ 我自己在写这条判据时又犯了一个错并当场自查出来:断言「文本 content 应保持 字符串」,而 anthropic 基座把文本转成块数组是**正常**的协议转换行为 —— 差点把 正确的实现判成错。判据要验的是「**日期块**有没有被插进来」这个事实,不是 content 的容器类型。 验证:internal/core 335s + internal/gateway 207s 全绿。 --- internal/core/channels_zcode_identity_test.go | 108 ++++++++++++++++++ internal/lua/plugins/channels.lua | 36 ++++-- 2 files changed, 137 insertions(+), 7 deletions(-) diff --git a/internal/core/channels_zcode_identity_test.go b/internal/core/channels_zcode_identity_test.go index e81e055..f62d474 100644 --- a/internal/core/channels_zcode_identity_test.go +++ b/internal/core/channels_zcode_identity_test.go @@ -294,3 +294,111 @@ func TestZcodeAdapterFailsLoudlyWithoutAnthropicBase(t *testing.T) { } t.Logf("✓ 基座缺失时响亮失败: %v", err) } + +// ★ 日期块必须**幂等**:已以 开头则不重复插。 +// +// 不判的后果:同一请求重试/多次 transform 时日期块叠成多层,改变 content 的 +// 前缀结构(上游检查的正是结构)。参考 withContextPrefix 有 startsWithSystemReminder。 +func TestZcodeContextPrefixIsIdempotent(t *testing.T) { + h := newZcodeIdentityHarness(t) + // 已是插入后的形态(首条 user 的 content[0] 就是 system-reminder) + in := `{"model":"m","messages":[{"role":"user","content":[` + + `{"type":"text","text":"\nOLD\n"},` + + `{"type":"text","text":"hi"}]}]}` + out, err := h.transformRequest(in) + if err != nil { + t.Fatal(err) + } + var req map[string]interface{} + if err := json.Unmarshal([]byte(out), &req); err != nil { + t.Fatal(err) + } + msgs := req["messages"].([]interface{}) + content := msgs[0].(map[string]interface{})["content"].([]interface{}) + n := 0 + for _, c := range content { + if b, ok := c.(map[string]interface{}); ok { + if s, _ := b["text"].(string); strings.Contains(s, "") { + n++ + } + } + } + if n != 1 { + t.Fatalf("日期块必须幂等(已存在则不重复插),实得 %d 个 system-reminder 块", n) + } +} + +// ★ 日期块只处理**第一条**消息,且它必须 role == "user"。 +// +// 反例(我第一版就是这样):往后扫第一个 user 消息 —— 若首条是 assistant, +// 官方行为是**不动**,而不是去找后面那条 user。判据钉住这个差别。 +func TestZcodeContextPrefixOnlyTouchesFirstMessage(t *testing.T) { + h := newZcodeIdentityHarness(t) + + t.Run("首条不是 user 时不动", func(t *testing.T) { + in := `{"model":"m","messages":[` + + `{"role":"assistant","content":"opening"},` + + `{"role":"user","content":"hi"}]}` + out, err := h.transformRequest(in) + if err != nil { + t.Fatal(err) + } + var req map[string]interface{} + if err := json.Unmarshal([]byte(out), &req); err != nil { + t.Fatal(err) + } + msgs := req["messages"].([]interface{}) + // ⚠ 注意:文本会被 anthropic 基座转成**块数组**(协议转换的正常行为), + // 所以这里不能断言 content 仍是字符串 —— 我第一版就这么断言,把正确的 + // 实现判成了错。判据要验的是「**日期块**有没有被插进来」这个事实。 + noReminder := func(content interface{}) bool { + switch c := content.(type) { + case string: + return !strings.Contains(c, "") + case []interface{}: + for _, e := range c { + if b, ok := e.(map[string]interface{}); ok { + if t, _ := b["text"].(string); strings.Contains(t, "") { + return false + } + } + } + } + return true + } + // 首条(assistant)不该被插日期块 + if !noReminder(msgs[0].(map[string]interface{})["content"]) { + t.Fatal("首条是 assistant 时不该插日期块(官方只看第一条且要求 role=user)") + } + // 后面的 user 也**不该**被插(官方不往后找) + if !noReminder(msgs[1].(map[string]interface{})["content"]) { + t.Fatal("日期块被插到了后面的 user 消息上 —— 官方只看第一条") + } + // 且原文本内容必须保留(转换不能丢内容) + b, _ := json.Marshal(msgs[0]) + if !strings.Contains(string(b), "opening") { + t.Fatal("首条 assistant 的内容丢了") + } + }) + + t.Run("首条是 user 时插入", func(t *testing.T) { + in := `{"model":"m","messages":[{"role":"user","content":"hi"}]}` + out, err := h.transformRequest(in) + if err != nil { + t.Fatal(err) + } + var req map[string]interface{} + if err := json.Unmarshal([]byte(out), &req); err != nil { + t.Fatal(err) + } + msgs := req["messages"].([]interface{}) + content, ok := msgs[0].(map[string]interface{})["content"].([]interface{}) + if !ok { + t.Fatal("首条 user 的 content 应升级为块数组") + } + b0 := content[0].(map[string]interface{}) + if s, _ := b0["text"].(string); !strings.Contains(s, "") { + t.Fatalf("首条 user 的 content[0] 必须是日期块,实得 %q", s) + } + }) +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 2d68a95..b74ae2c 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -9250,18 +9250,40 @@ function adapter.transform_request(raw_body) req.system = buildSystemBlocks(caller, opts) - -- 日期块:插到**第一条** user 消息的 content 数组最前(官方形态)。 + -- ★ 日期块:只处理**第一条**消息,且它必须是 role == "user"(官方行为)。 + -- + -- 两条容易漏掉的规则(照参考 withContextPrefix,「优化」它们会偏离官方): + -- ① **幂等**:已以 开头则不重复插。不判的话,同一请求 + -- 在重试/多次 transform 时会把日期块叠成好几层,改变前缀结构; + -- ② **只看第一条**:若首条不是 user(例如 assistant 开场),**不动** —— + -- 不是"往后找一个 user 消息"。我第一版就是往后找,与官方行为不符。 local msgs = req.messages if type(msgs) == "table" then - for _, m in ipairs(msgs) do - if type(m) == "table" and m.role == "user" then - local c = m.content + local first = msgs[1] + if type(first) == "table" and first.role == "user" then + local c = first.content + -- 已插过(字符串或块数组两种形态都要判)→ 不重复插。 + -- ⚠ 长度必须用 #SR 算,不能写死 —— 我第一版写 `sub(1, 16)` 而 + -- "" 是 **17** 字符,判断**永远为假** ⇒ 幂等失效、 + -- 重试时日期块叠加(判据当场抓到)。 + local SR = "" + local already = false + if type(c) == "string" then + already = c:sub(1, #SR) == SR + elseif type(c) == "table" and type(c[1]) == "table" then + local t = c[1].text + already = type(t) == "string" and t:sub(1, #SR) == SR + end + if not already then + local blk = contextPrefixBlock() if type(c) == "table" then - table.insert(c, 1, contextPrefixBlock()) + table.insert(c, 1, blk) elseif type(c) == "string" then - m.content = { contextPrefixBlock(), { type = "text", text = c } } + -- 纯文本 content 转成块数组(官方就是数组形态) + first.content = { blk, { type = "text", text = c } } + else + first.content = { blk } end - break end end end