llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
81 lines
3.6 KiB
Go
81 lines
3.6 KiB
Go
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 切坏了")
|
||
}
|
||
}
|