Skip to content
9 changes: 5 additions & 4 deletions agent/args_repair_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,9 @@ func TestRepairThenSanitize_Compose(t *testing.T) {
}
}

// TestRewriteToolCallArgsForHistory_Repairs 入历史路径:空/坏 arguments 被修复,
// Write 大 content 的省略逻辑照常工作(修复在前、省略在后)。
// TestRewriteToolCallArgsForHistory_Repairs 入历史路径:空/坏 arguments 被修复;
// 当前实现不再折叠 Write 参数 —— 大 content 原样保留(由调用方按 elidedWriteInfo
// 整体移除并渲染执行记录)。
func TestRewriteToolCallArgsForHistory_Repairs(t *testing.T) {
big := strings.Repeat("x", maxInlineWriteContentBytes+1)
bigArgs, _ := json.Marshal(map[string]string{"path": "a.txt", "content": big})
Expand All @@ -162,8 +163,8 @@ func TestRewriteToolCallArgsForHistory_Repairs(t *testing.T) {
if got := out[1].Function.Arguments; got != `{"path":"a.go"}` {
t.Fatalf("截断 arguments 应被补全, got %q", got)
}
if !json.Valid([]byte(out[2].Function.Arguments)) || strings.Contains(out[2].Function.Arguments, big) {
t.Fatalf("Write 大 content 应被省略且保持合法 JSON: %q", out[2].Function.Arguments)
if !json.Valid([]byte(out[2].Function.Arguments)) || !strings.Contains(out[2].Function.Arguments, big) {
t.Fatalf("Write 大 content 应原样保留(仅修 JSON): %q", out[2].Function.Arguments)
}
// 执行用的原始 toolCalls 不受影响
if in[0].Function.Arguments != `` || in[1].Function.Arguments != `{"path":"a.go` {
Expand Down
10 changes: 9 additions & 1 deletion agent/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,15 @@ func CutHistory(history []ChatMessage, cutIdx int) []ChatMessage {
// 只认 user 就找不到切点,压缩要么退到最前面(等于不压)、要么把整个长轮全保住;
// - tool 消息必须紧跟发起调用的 assistant,切在它上面会留下孤儿 tool → API 400
// (见 sanitizeToolPairs);而切在 assistant 上,它的 tool 结果自然跟着一起保留,配对不坏。
func isTurnBoundary(m ChatMessage) bool { return m.Role == "user" || m.Role == "assistant" }
// isTurnBoundary 判断消息是否为"对话轮边界"(user/assistant)。
// 系统注入的执行记录(IsExecRecord)虽然 role=user,但不是真实对话轮 —— 不计为边界,
// 否则每个大 Write 都虚增一轮、干扰压缩的轮数判断与切点。
func isTurnBoundary(m ChatMessage) bool {
if m.IsExecRecord {
return false
}
return m.Role == "user" || m.Role == "assistant"
}

// compactionTimeout 是摘要 LLM 调用的硬超时。没有它,卡住的请求会让压缩锁永远占住、把所有压缩堵死。
// 给得宽松(容纳大摘要生成 + 本地慢模型,如 4090D 上跑 qwen 摘要大历史,见 issue #201),
Expand Down
102 changes: 102 additions & 0 deletions agent/elision_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package agent

import (
"strings"
"testing"
)

// 问题 1:执行记录插入破坏 assistant/tool 配对 —— 摘除逻辑只摘成功折叠项,配对保持完整。
func TestStripElidedToolCalls_PairIntegrity(t *testing.T) {
// 混合批次:大 Write(成功折叠)+ Read(正常保留)。
big := `{"path":"big.go","content":` + jsonStr(strings.Repeat("a", 4000)) + `}`
tcs := []ToolCall{
mkTCID("call_write", "Write", big),
mkTCID("call_read", "Read", `{"path":"r.go"}`),
}
// 执行后:大 Write 成功 → 从 assistant tool_calls 摘除;Read 保留。
// 历史里 assistant 剩 [Read],其 tool 消息正常配对 → 不悬挂、不 400。
kept := stripElidedToolCalls(tcs, []string{"call_write"})
if len(kept) != 1 {
t.Fatalf("应只剩 Read, got %d 条", len(kept))
}
if kept[0].Function.Name != "Read" || kept[0].ID != "call_read" {
t.Fatalf("应保留 Read(call_read), got %s(%s)", kept[0].Function.Name, kept[0].ID)
}
// 配对完整性:assistant 剩余 tool_call 的每个 ID,都必须有对应的 tool 消息(模拟结果)。
results := map[string]bool{"call_read": true} // Read 的工具结果
for _, tc := range kept {
if !results[tc.ID] {
t.Fatalf("剩余 tool_call %s 无对应 tool 结果 → 悬挂", tc.ID)
}
}
}

// 问题 2:失败的大 Write 不被吞 —— 失败项不在 successIDs,保留 tool_call 供错误配对。
func TestStripElidedToolCalls_FailedWritePreserved(t *testing.T) {
big := `{"path":"fail.go","content":` + jsonStr(strings.Repeat("b", 4000)) + `}`
tcs := []ToolCall{
mkTCID("call_fail", "Write", big),
mkTCID("call_read", "Read", `{"path":"r.go"}`),
}
// 大 Write 失败 → 不进 successIDs;只摘除成功项(无)。
kept := stripElidedToolCalls(tcs, nil)
if len(kept) != 2 {
t.Fatalf("失败 Write 应保留, got %d 条", len(kept))
}
// 失败 Write 的 tool 错误消息能与保留的 tool_call 配对(assistant 仍有该调用)。
if kept[0].Function.Name != "Write" || kept[0].ID != "call_fail" || kept[1].Function.Name != "Read" {
t.Fatalf("应保留 [失败Write, Read], got %s(%s),%s(%s)", kept[0].Function.Name, kept[0].ID, kept[1].Function.Name, kept[1].ID)
}
// 失败 Write 有对应 tool 错误消息 → 配对、错误透传。
results := map[string]bool{"call_fail": true, "call_read": true}
for _, tc := range kept {
if !results[tc.ID] {
t.Fatalf("剩余 tool_call %s 无对应 tool 结果 → 悬挂", tc.ID)
}
}
}

// mkTCID 构造指定 ID 的工具调用(测试需要区分多个 tool_call,mkTC 固定 id1 不可用)。
func mkTCID(id, name, argsJSON string) ToolCall {
return ToolCall{ID: id, Type: "function", Function: ToolCallFunc{Name: name, Arguments: argsJSON}}
}

// 问题 3:elided 判定缓存一致 —— 截断 args 经修复后判定 ok,且记录内容非空;
// 对比原始截断 args 直接判定 !ok,证明"两处各算一次"会产生空路径/0 字节记录。
func TestCollectElided_Consistent(t *testing.T) {
// 模拟模型吐出的截断 arguments:content 被截成半截(issue #201 典型场景)。
truncated := `{"path":"t.go","content":"` + strings.Repeat("x", 2000) + `"`
// 原始截断 args:JSON 不完整 → elidedWriteInfo 判定失败(ok=false)。
if _, _, _, ok := elidedWriteInfo(truncated); ok {
t.Fatalf("原始截断 args 应判定 !ok")
}
// 修复后 args(repairArgsJSON 补全)→ 判定 ok,且缓存记录非空。
repaired := repairArgsJSON(truncated)
elided := collectElided([]ToolCall{mkTC("Write", repaired)})
if len(elided) != 1 {
t.Fatalf("修复后 args 应判定为 elide, got %d 条", len(elided))
}
for id, ew := range elided {
if ew.path == "" || ew.size == 0 || ew.lines == 0 {
t.Fatalf("缓存记录不应为空路径/0 字节: id=%s path=%q size=%d lines=%d", id, ew.path, ew.size, ew.lines)
}
}
// 两处共用同一份缓存 → 执行循环不再用原始 args 二次判定(避免空记录)。
_ = elided
}

// 问题 4:执行记录(role=user)不计对话轮边界 —— isTurnBoundary 过滤 IsExecRecord。
func TestIsTurnBoundary_ExecRecord(t *testing.T) {
if isTurnBoundary(ChatMessage{Role: "user", IsExecRecord: true}) {
t.Fatalf("执行记录不应算作轮边界")
}
if !isTurnBoundary(ChatMessage{Role: "user"}) {
t.Fatalf("普通 user 消息应算轮边界")
}
if !isTurnBoundary(ChatMessage{Role: "assistant"}) {
t.Fatalf("assistant 消息应算轮边界")
}
if isTurnBoundary(ChatMessage{Role: "tool"}) {
t.Fatalf("tool 消息不应算轮边界")
}
}
129 changes: 63 additions & 66 deletions agent/history_args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"encoding/json"
"strings"
"testing"
"unicode/utf8"
)

// mkTC 构造一个工具调用。
Expand All @@ -22,86 +21,76 @@ func argsMap(t *testing.T, argsJSON string) map[string]any {
return m
}

func TestElideWriteContent_LargeReplacedWithReference(t *testing.T) {
// 全中文大内容:老实现按字节切会切出半个 rune,这里应整体换成引用、不含乱码。
content := strings.Repeat("这是一段中文内容。", 200) // 远超 512 字节
in := mkTC("Write", `{"path":"a/b/中文.go","content":`+jsonStr(content)+`}`)

out := rewriteToolCallArgsForHistory([]ToolCall{in})
got := out[0].Function.Arguments
// bigWriteArgs 构造一个 content 远超 512 字节的 Write 调用。
func bigWriteArgs(path string) string {
return `{"path":"` + path + `","content":` + jsonStr(strings.Repeat("这是一段中文内容。", 200)) + `}`
}

if !utf8.ValidString(got) {
t.Fatalf("结果含非法 UTF-8(切出了半个字符): %q", got)
}
m := argsMap(t, got)
gotContent, _ := m["content"].(string)
if strings.Contains(gotContent, "这是一段中文内容") == true && len(gotContent) > 200 {
t.Fatalf("大 content 应被换成引用而非保留原文, got=%q", gotContent)
// 核心:大 content 的 Write 不再折叠参数,而是整体判定为"需外置" ——
// 由调用方从 assistant tool_calls 移除并渲染成独立执行记录。elidedWriteInfo 是判定依据。
func TestElidedWriteInfo_LargeContent(t *testing.T) {
in := mkTC("Write", bigWriteArgs("a/b/中文.go"))
path, size, lines, ok := elidedWriteInfo(in.Function.Arguments)
if !ok {
t.Fatalf("大 content 应判定为需外置")
}
if !strings.Contains(gotContent, "已写入") || !strings.Contains(gotContent, "Read") {
t.Fatalf("引用描述应含'已写入'和'Read'提示, got=%q", gotContent)
if path != "a/b/中文.go" {
t.Fatalf("path 解析错误, got=%q", path)
}
if !strings.Contains(gotContent, "a/b/中文.go") {
t.Fatalf("引用描述应含文件路径, got=%q", gotContent)
}
if p, _ := m["path"].(string); p != "a/b/中文.go" {
t.Fatalf("path 应保持不变, got=%q", p)
if size <= 512 || lines < 1 {
t.Fatalf("size/lines 应反映实际内容, size=%d lines=%d", size, lines)
}
}

func TestElideWriteContent_SmallKeptInline(t *testing.T) {
in := mkTC("Write", `{"path":"x.txt","content":"小内容"}`)
out := rewriteToolCallArgsForHistory([]ToolCall{in})
if out[0].Function.Arguments != in.Function.Arguments {
t.Fatalf("小 content 应原样保留\n want=%s\n got =%s", in.Function.Arguments, out[0].Function.Arguments)
func TestElidedWriteInfo_NotElided(t *testing.T) {
cases := []string{
`{"path":"x","content":"小内容"}`,
"{broken" + strings.Repeat("x", 600),
`{"path":"x.go","command":"` + strings.Repeat("a", 600) + `"}`,
}
for _, c := range cases {
if _, _, _, ok := elidedWriteInfo(c); ok {
t.Fatalf("不应判定为需外置: %q", c)
}
}
}

func TestElideWriteContent_NoHTMLEscape(t *testing.T) {
// path 含 < > &,大 content 触发重编码;不应被转成 < 等。
content := strings.Repeat("x", 600)
in := mkTC("Write", `{"path":"a<b>&c.go","content":`+jsonStr(content)+`}`)
got := rewriteToolCallArgsForHistory([]ToolCall{in})[0].Function.Arguments
// 若 < > & 被 HTML 转义,原始 JSON 里 path 会变成 a<b>&c.go,
// 就不再包含字面子串 "a<b>&c.go"。含字面子串即证明未转义。
if !strings.Contains(got, "a<b>&c.go") {
t.Fatalf("< > & 不应被 HTML 转义(path 应保持字面量), got=%s", got)
// 执行记录:固定模板,只含确定性元信息(路径/大小/行数),不含 content 预览
// (预览会成为新的模仿源)。模型读到的是"结果记录",语义上不会与 Write 调用范式混淆。
func TestExecRecordMessage_FixedTemplate(t *testing.T) {
msg := execRecordMessage("config.yaml", 1247, 42)
if msg.Role != "user" {
t.Fatalf("执行记录应为 user 消息(系统注入), got=%q", msg.Role)
}
for _, want := range []string{"Write 执行记录", "工具: Write", "config.yaml", "1247", "42", "状态: 成功"} {
if !strings.Contains(msg.Content, want) {
t.Fatalf("执行记录应含 %q, got=%q", want, msg.Content)
}
}
if p, _ := argsMap(t, got)["path"].(string); p != "a<b>&c.go" {
t.Fatalf("path 应原样保留 < > &, got=%q", p)
if strings.Contains(msg.Content, "content") || strings.Contains(msg.Content, "body") {
t.Fatalf("执行记录不应含内容预览, got=%q", msg.Content)
}
}

func TestUpdate_NeverTruncated(t *testing.T) {
// Update 即使 old_string/new_string 巨大也一律原样保留。
old := strings.Repeat("旧", 500)
nw := strings.Repeat("新", 500)
raw := `{"path":"f.go","old_string":` + jsonStr(old) + `,"new_string":` + jsonStr(nw) + `}`
in := mkTC("Update", raw)
// rewriteToolCallArgsForHistory 现在只修 JSON,不再折叠任何参数 ——
// 大 Write 的 content 原样保留(由调用方决定是否整体移除并渲染执行记录)。
func TestRewrite_KeepsLargeWriteContent(t *testing.T) {
raw := bigWriteArgs("big.go")
in := mkTC("Write", raw)
out := rewriteToolCallArgsForHistory([]ToolCall{in})
if out[0].Function.Arguments != raw {
t.Fatalf("Update 应原样保留,不裁剪\n want=%s\n got =%s", raw, out[0].Function.Arguments)
t.Fatalf("不折叠参数:大 Write content 应原样保留\n want=%s\n got =%s", raw, out[0].Function.Arguments)
}
}

func TestOtherTools_Untouched(t *testing.T) {
in := mkTC("Bash", `{"command":"`+strings.Repeat("echo ", 300)+`"}`)
func TestRewrite_RepairsBadJSON(t *testing.T) {
// 坏 arguments 仍应被修复为合法 JSON(issue #201 防严格后端 400)。
in := mkTC("Bash", `{"command":`)
out := rewriteToolCallArgsForHistory([]ToolCall{in})
if out[0].Function.Arguments != in.Function.Arguments {
t.Fatalf("非 Write 工具不应被改动")
}
}

func TestInvalidJSON_ReturnedAsIs(t *testing.T) {
// 超过阈值但不是合法 JSON:原样返回,不 panic。
broken := "{not json" + strings.Repeat("x", 600)
if got := elideWriteContent(broken); got != broken {
t.Fatalf("非法 JSON 应原样返回")
}
argsMap(t, out[0].Function.Arguments) // 合法 JSON 断言
}

func TestRewrite_DoesNotMutateOriginal(t *testing.T) {
// 执行仍用原始 toolCalls:确认原始未被改动。
content := strings.Repeat("y", 600)
raw := `{"path":"z.go","content":` + jsonStr(content) + `}`
orig := []ToolCall{mkTC("Write", raw)}
Expand All @@ -119,14 +108,22 @@ func TestRewrite_MixedBatch(t *testing.T) {
mkTC("Read", `{"path":"r.go"}`),
}
out := rewriteToolCallArgsForHistory(tcs)
if c, _ := argsMap(t, out[0].Function.Arguments)["content"].(string); !strings.Contains(c, "已写入") {
t.Fatalf("批次中的大 Write 应被换引用, got=%q", c)
for i := range out {
if out[i].Function.Arguments != tcs[i].Function.Arguments {
t.Fatalf("不折叠任何参数(仅修 JSON), 第 %d 个被改动:\n want=%s\n got =%s", i, tcs[i].Function.Arguments, out[i].Function.Arguments)
}
}
if out[1].Function.Arguments != tcs[1].Function.Arguments {
t.Fatalf("批次中的 Update 应原样")
}
if out[2].Function.Arguments != tcs[2].Function.Arguments {
t.Fatalf("批次中的 Read 应原样")
}

// 多轮连续 Write 大文件:每轮的 Write 都应被 elidedWriteInfo 识别为"需外置",
// 调用方据此把它从 assistant tool_calls 移除 → 历史里不存在任何
// "缺 content / 带折叠标记"的伪 Write,模型学到的 Write 范式始终完整。
func TestMultiTurn_AllLargeWritesElided(t *testing.T) {
for i := 0; i < 5; i++ {
in := mkTC("Write", `{"path":"f`+string(rune('a'+i))+`.txt","content":`+jsonStr(strings.Repeat("内容", 300))+`}`)
if _, _, _, ok := elidedWriteInfo(in.Function.Arguments); !ok {
t.Fatalf("第 %d 轮大 Write 应判定为需外置", i+1)
}
}
}

Expand Down
Loading