| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/provider" |
| 13 | "reasonix/internal/tool" |
| 14 | ) |
| 15 | |
| 16 | // Automatic prune/snip projections are gone. These APIs remain as no-ops so |
| 17 | // older call sites do not panic, but they never rewrite the model view. |
| 18 | func TestPruneAndSnipAreNoOps(t *testing.T) { |
| 19 | sess := &Session{Messages: []provider.Message{ |
| 20 | {Role: provider.RoleSystem, Content: "sys"}, |
| 21 | {Role: provider.RoleUser, Content: "task"}, |
| 22 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "t1", Name: "read_file", Arguments: "{}"}}}, |
| 23 | {Role: provider.RoleTool, ToolCallID: "t1", Name: "read_file", Content: strings.Repeat("x", 8000)}, |
| 24 | {Role: provider.RoleUser, Content: "next"}, |
| 25 | }} |
| 26 | a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 100_000, RecentKeep: 2}, event.Discard) |
| 27 | st, err := a.PruneStaleToolResults() |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | if st.Results != 0 { |
| 32 | t.Fatalf("prune results = %d, want 0", st.Results) |
| 33 | } |
| 34 | st, err = a.SnipStaleToolResults() |
| 35 | if err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | if st.Results != 0 { |
| 39 | t.Fatalf("snip results = %d, want 0", st.Results) |
| 40 | } |
| 41 | if got := a.currentProjectionVersion(); got != 0 { |
| 42 | t.Fatalf("projection version = %d, want 0", got) |
| 43 | } |
| 44 | for _, m := range sess.Snapshot() { |
| 45 | if m.Role == provider.RoleTool && m.Content != strings.Repeat("x", 8000) { |
| 46 | t.Fatal("canonical tool result was rewritten") |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | func TestBelowThresholdSamplingKeepsBoundedToolContentWithoutProjection(t *testing.T) { |
| 52 | full := strings.Repeat("完整结果", 10_000) |
| 53 | bounded := "legacy bounded result" |
| 54 | sess := &Session{Messages: []provider.Message{ |
| 55 | {Role: provider.RoleSystem, Content: "sys"}, |
| 56 | {Role: provider.RoleUser, Content: "read"}, |
| 57 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}}, |
| 58 | {Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: bounded, RawContent: full}, |
| 59 | }} |
| 60 | a := New(&countingProvider{reply: "unused"}, tool.NewRegistry(), sess, Options{ContextWindow: 1_000_000}, event.Discard) |
| 61 | req, err := a.buildSamplingRequest(context.Background(), CompactionTriggerPressure) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if got := req.req.Messages[3].Content; got != bounded { |
| 66 | t.Fatalf("provider tool result = %q, want bounded content", got) |
| 67 | } |
| 68 | if a.currentProjectionVersion() != 0 { |
| 69 | t.Fatalf("below-threshold request installed projection version %d", a.currentProjectionVersion()) |
| 70 | } |
| 71 | stored := sess.Snapshot()[3] |
| 72 | if stored.Content != bounded || stored.RawContent != full { |
| 73 | t.Fatal("request projection mutated compatibility storage") |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | func TestPruneSurvivesSummaryFailureAndRestart(t *testing.T) { |
| 78 | path := filepath.Join(t.TempDir(), "session.jsonl") |
| 79 | bigTool := strings.Repeat("🧪", 12_000) |
| 80 | bigWork := strings.Repeat("assistant work ", 12_000) |
| 81 | sess := &Session{Messages: []provider.Message{ |
| 82 | {Role: provider.RoleSystem, Content: "sys"}, |
| 83 | {Role: provider.RoleUser, Content: "task"}, |
| 84 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}}, |
| 85 | {Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: bigTool}, |
| 86 | {Role: provider.RoleAssistant, Content: bigWork}, |
| 87 | {Role: provider.RoleUser, Content: "tail"}, |
| 88 | }} |
| 89 | a := New(&fakeProvider{streamErr: errors.New("summary down")}, tool.NewRegistry(), sess, Options{ |
| 90 | ContextWindow: 60_000, CompactRatio: 0.50, SessionPath: path, |
| 91 | }, event.Discard) |
| 92 | if _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil { |
| 93 | t.Fatalf("pressure below hard ceiling should continue from prune: %v", err) |
| 94 | } |
| 95 | if a.currentProjectionVersion() != 1 || countToolResultsContaining(a.modelVisibleMessages(), toolPruneMarker) != 1 { |
| 96 | t.Fatalf("prune did not survive summary failure: version=%d view=%+v", a.currentProjectionVersion(), a.modelVisibleMessages()) |
| 97 | } |
| 98 | |
| 99 | restarted := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 60_000, CompactRatio: 0.50, SessionPath: path}, event.Discard) |
| 100 | if restarted.currentProjectionVersion() != 1 || countToolResultsContaining(restarted.modelVisibleMessages(), toolPruneMarker) != 1 { |
| 101 | t.Fatalf("restart lost prune projection: version=%d view=%+v", restarted.currentProjectionVersion(), restarted.modelVisibleMessages()) |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | func TestPruneSidecarWriteFailureRollsBackWithoutAppliedReceipt(t *testing.T) { |
| 106 | path := filepath.Join(t.TempDir(), "session.jsonl") |
| 107 | bigTool := strings.Repeat("界", toolPruneThresholdRunes+1) |
| 108 | sess := &Session{Messages: []provider.Message{ |
| 109 | {Role: provider.RoleSystem, Content: "sys"}, |
| 110 | {Role: provider.RoleUser, Content: "read"}, |
| 111 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-1", Name: "read_file", Arguments: "{}"}}}, |
| 112 | {Role: provider.RoleTool, ToolCallID: "call-1", Name: "read_file", Content: bigTool}, |
| 113 | }} |
| 114 | appliedEvents := 0 |
| 115 | sink := event.FuncSink(func(e event.Event) { |
| 116 | if e.Kind == event.ContextMaintenanceEvent && e.Maintenance != nil && e.Maintenance.Status == "applied" { |
| 117 | appliedEvents++ |
| 118 | } |
| 119 | }) |
| 120 | a := New(nil, tool.NewRegistry(), sess, Options{ContextWindow: 20_000, SessionPath: path}, sink) |
| 121 | // A directory at the sidecar destination makes atomic publication fail |
| 122 | // without relying on platform-specific permission behavior. |
| 123 | if err := os.Mkdir(ContextStatePath(path), 0o755); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | |
| 127 | a.sess.compactionRunMu.Lock() |
| 128 | advanced, err := a.pruneToolResultsToProjectionLocked(CompactionTriggerPressure) |
| 129 | a.sess.compactionRunMu.Unlock() |
| 130 | if err == nil { |
| 131 | t.Fatal("prune unexpectedly succeeded with an unwritable sidecar destination") |
| 132 | } |
| 133 | if advanced { |
| 134 | t.Fatal("failed prune reported projection progress") |
| 135 | } |
| 136 | if got := a.currentProjectionVersion(); got != 0 { |
| 137 | t.Fatalf("projection version = %d, want rollback to 0", got) |
| 138 | } |
| 139 | if a.sess.compactionState.LastReceipt != nil { |
| 140 | t.Fatalf("failed prune published in-memory receipt: %+v", a.sess.compactionState.LastReceipt) |
| 141 | } |
| 142 | if appliedEvents != 0 { |
| 143 | t.Fatalf("applied maintenance events = %d, want 0", appliedEvents) |
| 144 | } |
| 145 | if got := sess.Snapshot()[3].Content; got != bigTool { |
| 146 | t.Fatal("failed prune modified canonical tool content") |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // At the compact_ratio trigger, maintenance persists a prune projection first. |
| 151 | // If that projection clears pressure, no paid summary request is made. |
| 152 | func TestMaintenancePrunesBeforeSummaryAndStopsWhenPressureClears(t *testing.T) { |
| 153 | big := strings.Repeat("x", 10_000) |
| 154 | msgs := []provider.Message{ |
| 155 | {Role: provider.RoleSystem, Content: "sys"}, |
| 156 | {Role: provider.RoleUser, Content: "task"}, |
| 157 | } |
| 158 | for i := range 8 { |
| 159 | id := "t" + string(rune('a'+i)) |
| 160 | msgs = append(msgs, |
| 161 | provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "read_file", Arguments: "{}"}}}, |
| 162 | provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: "read_file", Content: big}, |
| 163 | ) |
| 164 | } |
| 165 | msgs = append(msgs, |
| 166 | provider.Message{Role: provider.RoleUser, Content: "tail"}, |
| 167 | provider.Message{Role: provider.RoleAssistant, Content: "ok"}, |
| 168 | ) |
| 169 | prov := &countingProvider{reply: "digest"} |
| 170 | a := New(prov, tool.NewRegistry(), &Session{Messages: msgs}, Options{ |
| 171 | ContextWindow: 30_000, CompactRatio: 0.5, RecentKeep: 2, |
| 172 | }, event.Discard) |
| 173 | if _, err := a.contextManager().Prepare(context.Background(), ContextPreparePolicy{Trigger: CompactionTriggerPressure}); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | if a.currentProjectionVersion() != 1 { |
| 177 | t.Fatalf("projection version = %d, want 1", a.currentProjectionVersion()) |
| 178 | } |
| 179 | if len(prov.got) != 0 { |
| 180 | t.Fatalf("summarizer calls = %d, want 0", len(prov.got)) |
| 181 | } |
| 182 | visible := a.modelVisibleMessages() |
| 183 | if got := countToolResultsContaining(visible, toolPruneMarker); got != 8 { |
| 184 | t.Fatalf("pruned tool results = %d, want 8", got) |
| 185 | } |
| 186 | if got := a.sess.compactionState.LastReceipt.Action; got != "prune" { |
| 187 | t.Fatalf("receipt action = %q, want prune", got) |
| 188 | } |
| 189 | for _, msg := range a.sess.conversation.Snapshot() { |
| 190 | if msg.Role == provider.RoleTool && msg.Content != big { |
| 191 | t.Fatal("canonical tool result was modified") |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func countToolResultsContaining(msgs []provider.Message, marker string) int { |
| 197 | n := 0 |
| 198 | for _, msg := range msgs { |
| 199 | if msg.Role == provider.RoleTool && strings.Contains(msg.Content, marker) { |
| 200 | n++ |
| 201 | } |
| 202 | } |
| 203 | return n |
| 204 | } |
| 205 | |
| 206 | func TestSnipStrategyStillBuildsBoundedCompatibilityContent(t *testing.T) { |
| 207 | a := &Agent{svc: agentServices{tools: tool.NewRegistry()}} |
| 208 | s := a.snipStrategyFor("read_file") |
| 209 | if s.head <= 0 || s.tail <= 0 { |
| 210 | t.Fatalf("snip strategy for read_file = %+v", s) |
| 211 | } |
| 212 | body, notice := truncateToolOutputFor(strings.Repeat("x", maxToolOutputBytes+100), "read_file", "call-1") |
| 213 | if notice == "" || !strings.Contains(body, "call_id=call-1") { |
| 214 | t.Fatalf("first-visible truncation missing marker: notice=%q body=%.200q", notice, body) |
| 215 | } |
| 216 | if len(body) > maxToolOutputBytes+200 { |
| 217 | t.Fatalf("bounded body still oversized: %d", len(body)) |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | func TestModelInputMessagesKeepsBoundedToolContent(t *testing.T) { |
| 222 | msgs := []provider.Message{ |
| 223 | {Role: provider.RoleUser, Content: "display user", RawContent: "raw user"}, |
| 224 | {Role: provider.RoleTool, Content: "bounded", RawContent: "complete tool result", ToolCallID: "call-1"}, |
| 225 | } |
| 226 | |
| 227 | got := modelInputMessages(msgs) |
| 228 | if got[0].Content != "display user" { |
| 229 | t.Fatalf("user content = %q, want display form", got[0].Content) |
| 230 | } |
| 231 | if got[1].Content != "bounded" { |
| 232 | t.Fatalf("tool content = %q, want bounded Content", got[1].Content) |
| 233 | } |
| 234 | if got[1].RawContent != "" { |
| 235 | t.Fatalf("provider-bound RawContent = %q, want empty", got[1].RawContent) |
| 236 | } |
| 237 | if msgs[1].Content != "bounded" || msgs[1].RawContent != "complete tool result" { |
| 238 | t.Fatalf("stored message was mutated: %+v", msgs[1]) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | func TestPruneToolResultUsesUnicodeCodePoints(t *testing.T) { |
| 243 | head := strings.Repeat("界", toolPruneHeadRunes) |
| 244 | middle := strings.Repeat("🧪", toolPruneThresholdRunes-toolPruneHeadRunes-toolPruneTailRunes+1) |
| 245 | tail := strings.Repeat("尾", toolPruneTailRunes) |
| 246 | original := head + middle + tail |
| 247 | |
| 248 | got, changed := pruneToolResultContent(original) |
| 249 | if !changed { |
| 250 | t.Fatal("oversized tool result was not pruned") |
| 251 | } |
| 252 | want := head + toolPruneMarker + tail |
| 253 | if got != want { |
| 254 | t.Fatalf("pruned content mismatch: got runes=%d want runes=%d", len([]rune(got)), len([]rune(want))) |
| 255 | } |
| 256 | if _, changed := pruneToolResultContent(strings.Repeat("🧪", toolPruneThresholdRunes)); changed { |
| 257 | t.Fatal("tool result at threshold must remain intact") |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | // Pruning must allocate for the retained head/marker/tail only. Converting the |
| 262 | // complete input to []rune makes a large ASCII tool result consume roughly |
| 263 | // four additional bytes per source byte exactly when maintenance is trying to |
| 264 | // recover from context pressure. |
| 265 | func TestPruneToolResultAllocationIsBoundedByRetainedContent(t *testing.T) { |
| 266 | const inputBytes = 256 << 10 |
| 267 | large := strings.Repeat("x", inputBytes) |
| 268 | result := testing.Benchmark(func(b *testing.B) { |
| 269 | b.ReportAllocs() |
| 270 | for range b.N { |
| 271 | got, changed := pruneToolResultContent(large) |
| 272 | if !changed || len(got) == 0 { |
| 273 | b.Fatal("large tool result was not pruned") |
| 274 | } |
| 275 | } |
| 276 | }) |
| 277 | if got := result.AllocedBytesPerOp(); got > 64<<10 { |
| 278 | t.Fatalf("prune allocated %d bytes/op for a %d-byte input; want allocation bounded by retained content", got, inputBytes) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | func TestPrunePreservesToolEnvelopeMetadata(t *testing.T) { |
| 283 | exit := 7 |
| 284 | original := provider.Message{ |
| 285 | Role: provider.RoleTool, Name: "bash", ToolCallID: "call-7", |
| 286 | Content: strings.Repeat("🧪", toolPruneThresholdRunes+1), |
| 287 | Images: []string{"data:image/png;base64,AA=="}, CreatedAt: 77, |
| 288 | ToolExecution: &provider.ToolExecution{State: tool.ShellStateFailed, ExitCode: &exit}, |
| 289 | } |
| 290 | sess := &Session{Messages: []provider.Message{ |
| 291 | {Role: provider.RoleSystem, Content: "sys"}, |
| 292 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call-7", Name: "bash", Arguments: `{}`}}}, |
| 293 | original, |
| 294 | }} |
| 295 | a := New(nil, tool.NewRegistry(), sess, Options{}, event.Discard) |
| 296 | a.sess.compactionRunMu.Lock() |
| 297 | advanced, err := a.pruneToolResultsToProjectionLocked(CompactionTriggerPressure) |
| 298 | a.sess.compactionRunMu.Unlock() |
| 299 | if err != nil || !advanced { |
| 300 | t.Fatalf("prune advanced=%v err=%v", advanced, err) |
| 301 | } |
| 302 | got := a.sess.compactionState.Projection.Messages[2] |
| 303 | if got.Role != original.Role || got.Name != original.Name || got.ToolCallID != original.ToolCallID || got.CreatedAt != original.CreatedAt { |
| 304 | t.Fatalf("tool envelope changed: got=%+v want=%+v", got, original) |
| 305 | } |
| 306 | if len(got.Images) != 1 || got.Images[0] != original.Images[0] || got.ToolExecution != original.ToolExecution { |
| 307 | t.Fatalf("tool metadata changed: got=%+v want=%+v", got, original) |
| 308 | } |
| 309 | if !strings.Contains(got.Content, toolPruneMarker) { |
| 310 | t.Fatalf("tool body was not pruned: runes=%d", len([]rune(got.Content))) |
| 311 | } |
| 312 | } |
| 313 |