diff --git a/internal/services/channelbridge/flowgate_test.go b/internal/services/channelbridge/flowgate_test.go index bc5f94d..2fe93a1 100644 --- a/internal/services/channelbridge/flowgate_test.go +++ b/internal/services/channelbridge/flowgate_test.go @@ -3,6 +3,7 @@ package channelbridge import ( "bytes" "context" + "encoding/json" "log/slog" "strings" "testing" @@ -329,3 +330,88 @@ func TestParseKeepIndices(t *testing.T) { t.Fatal("expected error for non-JSON") } } + +// ── M24:审查解析的**接线**(BOM 韧性只有在这里才算真的生效)──────────────── +// +// parseKeepIndices 现在是 adapter.ParseReviewIndices 的一行委派;adapter 侧有自己的 +// 用例,但「委派还在不在」只有本文件的用例能锁住 —— 谁把它改回裸 json.Unmarshal, +// 下面这几条就会红。生产症状(`~/cy.log`,9 天):1,909 次审查里 1,301 次解析失败, +// 其中 1,288 次错误串逐字是 `invalid character 'ï' looking for beginning of value` +// (对端在数组前回带了 UTF-8 BOM),每一次都白烧一次模型调用。 +// +// 另一条已确认接受的语义代价(M24,塔裁决):审查移出准入路径后 review_pruned 不再 +// 从门口丢消息(实测 41–74 条/日 现在会进流水线)—— 这是「不阻塞读循环」的必然代价。 +// rate_limited 的门口丢与 FIFO 上限修剪**一字不改**:前者由本文件既有的 +// TestFlowGate_RateLimitDropsNonOwnerAndAudits 钉住,后者由 adapter 侧的 +// TestFlowGate_CapStillEvictsOnAdmission 钉住。 + +// bomReviewOutput 是审查模型网关的**原样**回包:UTF-8 BOM 紧接 JSON 数组。写死字节 +// (而不是 \ufeff 转义或运行时拼装),使「夹具被谁悄悄去掉 BOM」本身就会红。 +const bomReviewOutput = "\xef\xbb\xbf[0, 1, 2, 3, 4, 5]" + +// TestParseKeepIndices_ToleratesBOMWiring 锁住接线:生产路径的 parseKeepIndices 必须 +// 吃下带 BOM 的对端回包。 +func TestParseKeepIndices_ToleratesBOMWiring(t *testing.T) { + t.Parallel() + + // 夹具真实性:它必须以 BOM 字节开头,且裸 json.Unmarshal 在它上面必须失败 —— + // 否则这条用例就不再模拟任何生产故障。 + if !strings.HasPrefix(bomReviewOutput, "\xef\xbb\xbf") { + t.Fatal("fixture lost its BOM bytes: this case no longer models the production failure") + } + var raw []int + if err := json.Unmarshal([]byte(bomReviewOutput), &raw); err == nil { + t.Fatal("strict json.Unmarshal accepted the BOM fixture; the case is meaningless now") + } + + got, err := parseKeepIndices(bomReviewOutput, 6) + if err != nil { + t.Fatalf("parseKeepIndices(BOM) error = %v, want nil (delegation to adapter.ParseReviewIndices lost?)", err) + } + if len(got) != 6 { + t.Fatalf("keep = %v, want [0 1 2 3 4 5]", got) + } + for i := range got { + if got[i] != i { + t.Fatalf("keep = %v, want ascending [0 1 2 3 4 5]", got) + } + } +} + +// TestParseKeepIndices_StillFiltersAfterDelegation 委派之后过滤口径不变:越界、负数、 +// 重复的编号仍被丢弃,结果按升序(模型幻觉出的数字绝不能淘汰真实消息)。 +func TestParseKeepIndices_StillFiltersAfterDelegation(t *testing.T) { + t.Parallel() + + got, err := parseKeepIndices("\xef\xbb\xbf[3, -1, 3, 1, 99]", 4) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(got) != 2 || got[0] != 1 || got[1] != 3 { + t.Fatalf("keep = %v, want [1 3] (out-of-range/negative/dup dropped, ascending)", got) + } +} + +// TestNewLLMReviewer_ToleratesBOMOutput 走**整条生产路径**:审查模型回带 BOM ⇒ +// NewLLMReviewer 产出的 Reviewer 必须给出保留编号,而不是报 unparsable 退回 FIFO。 +// (它证明的是密封在函数里的 wiring,而不只是 parseKeepIndices 这一个符号。) +func TestNewLLMReviewer_ToleratesBOMOutput(t *testing.T) { + t.Parallel() + + review := NewLLMReviewer(&mockLLM{content: bomReviewOutput}, nil) + msgs := make([]adapter.UnifiedMessage, 0, 6) + for i := 0; i < 6; i++ { + msgs = append(msgs, adapter.UnifiedMessage{ + SenderID: "u", + Content: "m", + ContentType: adapter.ContentText, + }) + } + keep, err := review(context.Background(), msgs) + if err != nil { + t.Fatalf("review with BOM output: %v, want nil error", err) + } + if len(keep) != 6 { + t.Fatalf("keep = %v, want all six indices", keep) + } +}