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