package agent import ( "strings" "testing" "unicode/utf8" "git.gopher.ink/mirrors2/AutoZero/llm" ) // 截断类代码的共性坑:`s[:n]` 的 n 是**字节**下标,而这里的文本大量是中文(每字 3 字节) // 与 emoji(4 字节)。切在中间不会报错,只会留下一个非法字节序列——发给模型是乱码, // 某些网关直接 400,写进会话 JSONL 后连回读都不干净。 // 这几条用例的共同断言是 utf8.ValidString:它们在修复前全部会失败(预算恰好落在半个字上)。 const cjkLine = "把中文标题与 emoji 😀 都留下,别劈开" // 每轮 3 字节为主,含一个 4 字节 emoji func TestPreviewKeepsUTF8(t *testing.T) { // 逐个预算长度试:只要有一个切点落在字符中间,preview 就会产出非法序列。 long := strings.Repeat(cjkLine, 30) for n := 1; n <= utf8.RuneCountInString(long); n++ { // preview 的预算固定 60 字,所以这里改的是内容里"第 60 个字符落在哪"。 got := preview(strings.Repeat("中", n) + " " + long) if !utf8.ValidString(got) { t.Fatalf("preview 在偏移 %d 处劈开字符:%q", n, got) } } } func TestCompactKeepsUTF8(t *testing.T) { // 工具参数里的中文路径/内容是常态(write 的 content、bash 的 echo)。 input := `{"path":"` + strings.Repeat("中文路径", 60) + `","content":"` + strings.Repeat("内容", 200) + `"}` if got := compact(input); !utf8.ValidString(got) { t.Fatalf("compact 产出非法 UTF-8:%q", got) } // 非 JSON 入参走原样截断那条路,同样不能切坏。 raw := strings.Repeat("查询", 300) if got := compact(raw); !utf8.ValidString(got) { t.Fatalf("compact 对非 JSON 入参产出非法 UTF-8:%q", got) } } // TestSerializeForSummaryKeepsUTF8 是最贵的一处:这条串就是发给模型的摘要提示词, // 而 perMsgCap 是字节预算,中文消息下几乎必然切在半个字上。 func TestSerializeForSummaryKeepsUTF8(t *testing.T) { msgs := []llm.Message{ {Role: llm.RoleUser, Content: strings.Repeat("用户诉求", 200)}, {Role: llm.RoleAssistant, Content: strings.Repeat("回答内容", 200)}, {Role: llm.RoleAssistant, Content: "先看一下", ToolCalls: []llm.ToolCall{ {ID: "c1", Name: "bash", Input: `{"command":"echo ` + strings.Repeat("输出中文", 500) + `"}`}, }}, {Role: llm.RoleTool, ToolCallID: "c1", Content: strings.Repeat("工具结果", 400)}, } for _, cap := range []int{1, 5, 7, 64, 200, 1999, 2001} { got := serializeForSummary(msgs, cap, 0) if !utf8.ValidString(got) { t.Errorf("perMsgCap=%d 时摘要输入含非法字符", cap) } } } // TestTruncateToolResultsStillAligns 是既有正确实现的护栏(这两处本来就退到字符边界): // 它们不该被后来的"统一截断"重构换掉语义——头尾都要能独立成为一个合法串。 func TestTruncateToolResultsStillAligns(t *testing.T) { content := strings.Repeat("中文输出", 5000) + "😀" out := truncateToolResults([]llm.Message{{Role: llm.RoleTool, ToolCallID: "1", Content: content}}, 1000) if !utf8.ValidString(out[0].Content) { t.Errorf("截断后的工具结果非法:%q", out[0].Content[len(out[0].Content)-40:]) } // 预算放不下一个字符时要给空串,而不是半个;放得下时就给整字。 if got := cutUTF8Head(content, 2); got != "" { t.Errorf("2 字节预算放不下一个中文,应得空串,得到 %q", got) } if got := cutUTF8Head(content, 4); got != "中" { t.Errorf("4 字节预算应正好装下第一个中文,得到 %q", got) } if !utf8.ValidString(cutUTF8Tail(content, 5)) { t.Error("cutUTF8Tail 切坏了") } }