Why: the completion doc's N2 requires old tool results to become file paths (ZCode compact pattern) and cache-friendly prefix rules; today the micro-compactor only left a transcript pointer and nothing pinned prefix stability. alignToolTail had no direct edge tests. Evidence: GOWORK=off go vet + go test -count=1 ./... green; new TestClearOldToolResultsSpillsToDisk / TestSpillResultSanitizesCallID / TestAlignToolTailEdges / TestSystemTextDeterministicAcrossIdenticalBuilds; legacy placeholder bytes unchanged when ResultsDir is empty (TestClearOldToolResultsEmptyDirKeepsLegacyPlaceholder). Impact: opt-in via Config.ResultsDir; the native adapter wires it to <workDir>/.agentx/results. No behavior change for other hosts. Refs: 2026-10-07-agent-stack-n2-plan WP1
137 lines
4.1 KiB
Go
137 lines
4.1 KiB
Go
package session
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"agent-native/runtime/llm"
|
|
)
|
|
|
|
func TestClearOldToolResultsKeepsLastFive(t *testing.T) {
|
|
var msgs []llm.Message
|
|
for i := 0; i < 8; i++ {
|
|
id := string(rune('a' + i))
|
|
msgs = append(msgs,
|
|
llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: id, Name: "read_file"}}},
|
|
llm.Message{Role: llm.RoleTool, Name: "read_file", ToolCallID: id, Content: "正文-" + id},
|
|
)
|
|
}
|
|
out := ClearOldToolResults(msgs, 5)
|
|
if len(out) != len(msgs) {
|
|
t.Fatalf("len %d, want %d", len(out), len(msgs))
|
|
}
|
|
cleared := 0
|
|
for i, m := range out {
|
|
if m.Role != llm.RoleTool {
|
|
if m.Role != msgs[i].Role || len(m.ToolCalls) != len(msgs[i].ToolCalls) {
|
|
t.Fatalf("pairing changed at %d", i)
|
|
}
|
|
continue
|
|
}
|
|
if m.ToolCallID != msgs[i].ToolCallID {
|
|
t.Fatalf("tool id changed at %d", i)
|
|
}
|
|
if strings.Contains(m.Content, "旧工具结果已清理") {
|
|
cleared++
|
|
if !strings.Contains(m.Content, m.ToolCallID) {
|
|
t.Fatalf("placeholder missing call id: %s", m.Content)
|
|
}
|
|
} else if m.Content != msgs[i].Content {
|
|
t.Fatalf("recent result changed: %s", m.Content)
|
|
}
|
|
}
|
|
if cleared != 3 {
|
|
t.Fatalf("cleared %d, want 3", cleared)
|
|
}
|
|
for _, m := range out[len(out)-10:] {
|
|
if m.Role == llm.RoleTool && strings.Contains(m.Content, "旧工具结果已清理") {
|
|
t.Fatalf("last five were cleared: %s", m.ToolCallID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNeedsClearUsesConservativeWindow(t *testing.T) {
|
|
if NeedsClear(10, 0, 0, 0) {
|
|
t.Fatal("small estimate must fit the default window")
|
|
}
|
|
if !NeedsClear(DefaultContextWindow, 0, DefaultOutputReserve, DefaultContextBuffer) {
|
|
t.Fatal("estimate at the default window must clear")
|
|
}
|
|
}
|
|
|
|
// ---- N2 WP1:旧工具结果落盘(路径占位)----
|
|
|
|
func spillFixture() []llm.Message {
|
|
msgs := []llm.Message{{Role: llm.RoleUser, Content: "u"}}
|
|
for i := 0; i < 7; i++ {
|
|
id := "call-" + string(rune('a'+i))
|
|
msgs = append(msgs,
|
|
llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: id, Name: "read", Arguments: "{}"}}},
|
|
llm.Message{Role: llm.RoleTool, ToolCallID: id, Name: "read", Content: "完整结果内容-" + id})
|
|
}
|
|
return msgs
|
|
}
|
|
|
|
func TestClearOldToolResultsSpillsToDisk(t *testing.T) {
|
|
dir := t.TempDir()
|
|
out := ClearOldToolResultsTo(spillFixture(), 5, dir)
|
|
if len(out) != len(spillFixture()) {
|
|
t.Fatal("消息条数不得变")
|
|
}
|
|
// 最旧两条被落盘,占位带路径。
|
|
for i, id := range []string{"call-a", "call-b"} {
|
|
path := filepath.Join(dir, id+".txt")
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("落盘文件缺失:%v", err)
|
|
}
|
|
if string(b) != "完整结果内容-"+id {
|
|
t.Fatalf("落盘内容不符:%q", b)
|
|
}
|
|
placeholder := out[2+i*2].Content
|
|
if !strings.Contains(placeholder, path) {
|
|
t.Fatalf("占位未带路径:%q", placeholder)
|
|
}
|
|
}
|
|
// 保留的最近 5 条正文不动。
|
|
if out[len(out)-1].Content != "完整结果内容-call-g" {
|
|
t.Fatalf("最近结果被误改:%q", out[len(out)-1].Content)
|
|
}
|
|
}
|
|
|
|
func TestClearOldToolResultsEmptyDirKeepsLegacyPlaceholder(t *testing.T) {
|
|
out := ClearOldToolResultsTo(spillFixture(), 5, "")
|
|
legacy := ClearOldToolResults(spillFixture(), 5)
|
|
for i := range out {
|
|
if out[i].Content != legacy[i].Content {
|
|
t.Fatalf("空 dir 应与旧行为逐字节一致:msg %d", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClearOldToolResultsSpillFailureFallsBack(t *testing.T) {
|
|
// 不可写目录:占位回落为转录指针,不阻断。
|
|
blocked := filepath.Join(t.TempDir(), "file")
|
|
if err := os.WriteFile(blocked, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out := ClearOldToolResultsTo(spillFixture(), 5, filepath.Join(blocked, "sub"))
|
|
if !strings.Contains(out[2].Content, "会话记录中的调用 call-a") {
|
|
t.Fatalf("写盘失败应回落转录指针:%q", out[2].Content)
|
|
}
|
|
}
|
|
|
|
func TestSpillResultSanitizesCallID(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path, ok := spillResult(dir, "call/../evil\x00id", "x")
|
|
if !ok {
|
|
t.Fatal("spill 失败")
|
|
}
|
|
base := filepath.Base(path)
|
|
if strings.Contains(base, "/") || strings.ContainsRune(base, 0) || filepath.Dir(path) != dir {
|
|
t.Fatalf("文件名未消毒或越出目录:%q", path)
|
|
}
|
|
}
|