206 lines
6.2 KiB
Go
206 lines
6.2 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
|
||
"reasonix/internal/event"
|
||
"reasonix/internal/tool"
|
||
)
|
||
|
||
// fatTool returns a fixed-size blob, standing in for a real read_file / bash
|
||
// whose output dominates the recent (verbatim-kept) tail of the session.
|
||
type fatTool struct{ blob string }
|
||
|
||
func (fatTool) Name() string { return "fat_read" }
|
||
func (fatTool) Description() string { return "read a large file" }
|
||
func (fatTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object","properties":{}}`) }
|
||
func (fatTool) ReadOnly() bool { return true }
|
||
func (f fatTool) Execute(context.Context, json.RawMessage) (string, error) {
|
||
return f.blob, nil
|
||
}
|
||
|
||
// loopMock emits exactly one tool call per user turn (a tool call when the last
|
||
// message is the user's, a final answer when it is the tool result), so each Run
|
||
// does one tool round — the step that triggers maybeCompact. finalText overrides
|
||
// the per-turn closing answer so a test can grow the session with assistant text
|
||
// (which pruning never touches) instead of tool output.
|
||
type loopMock struct {
|
||
t *testing.T
|
||
rounds int
|
||
finalText string
|
||
}
|
||
|
||
func lastRole(msgs []json.RawMessage) string {
|
||
if len(msgs) == 0 {
|
||
return ""
|
||
}
|
||
var m struct {
|
||
Role string `json:"role"`
|
||
}
|
||
_ = json.Unmarshal(msgs[len(msgs)-1], &m)
|
||
return m.Role
|
||
}
|
||
|
||
func (m *loopMock) handler(w http.ResponseWriter, r *http.Request) {
|
||
body, _ := io.ReadAll(r.Body)
|
||
if isSummarizeRequest(body) {
|
||
writeSSE(w, m.t,
|
||
streamChunk(deltaText("- goal: keep going\n- pending: continue the task")),
|
||
finishChunk("stop"),
|
||
usageChunk(80, 30, 0, 80))
|
||
return
|
||
}
|
||
|
||
msgs := decodeMessages(body)
|
||
promptTok := charsOf(msgs) / 4
|
||
|
||
if lastRole(msgs) == "tool" {
|
||
text := m.finalText
|
||
if text == "" {
|
||
text = "Done with this step."
|
||
}
|
||
writeSSE(w, m.t,
|
||
streamChunk(deltaText(text)),
|
||
finishChunk("stop"),
|
||
usageChunk(promptTok, 20, 0, promptTok))
|
||
return
|
||
}
|
||
|
||
m.rounds++
|
||
writeSSE(w, m.t,
|
||
streamChunk(deltaToolCall(m.rounds, "fat_read", "{}")),
|
||
finishChunk("tool_calls"),
|
||
usageChunk(promptTok, 20, 0, promptTok))
|
||
}
|
||
|
||
// compactionsPerTurn drives `turns` user messages through a fresh agent wired to
|
||
// loopMock and reports, per turn, how many compactions started and whether an
|
||
// auto-compaction-paused notice was seen.
|
||
func compactionsPerTurn(t *testing.T, windowTok int, blob, finalText string, turns int) (perTurn []int, paused bool, prunes int) {
|
||
t.Helper()
|
||
mock := &loopMock{t: t, finalText: finalText}
|
||
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
|
||
defer srv.Close()
|
||
|
||
reg := tool.NewRegistry()
|
||
reg.Add(fatTool{blob: blob})
|
||
|
||
a, _ := newAgent(t, srv.URL, reg, windowTok, 4)
|
||
started := 0
|
||
a.sink = event.FuncSink(func(e event.Event) {
|
||
switch e.Kind {
|
||
case event.CompactionStarted:
|
||
started++
|
||
case event.Notice:
|
||
if strings.Contains(e.Text, "Automatic context cleanup paused") {
|
||
paused = true
|
||
}
|
||
if strings.Contains(e.Text, "pruned") {
|
||
prunes++
|
||
}
|
||
}
|
||
})
|
||
|
||
perTurn = make([]int, turns)
|
||
for i := 0; i < turns; i++ {
|
||
before := started
|
||
if err := a.Run(context.Background(), fmt.Sprintf("turn %d: keep going, continue the work", i)); err != nil {
|
||
t.Fatalf("Run %d: %v", i, err)
|
||
}
|
||
perTurn[i] = started - before
|
||
}
|
||
return perTurn, paused, prunes
|
||
}
|
||
|
||
func consecutiveCompactingTurns(perTurn []int) int {
|
||
worst, run := 0, 0
|
||
for _, n := range perTurn {
|
||
if n > 0 {
|
||
run++
|
||
if run > worst {
|
||
worst = run
|
||
}
|
||
} else {
|
||
run = 0
|
||
}
|
||
}
|
||
return worst
|
||
}
|
||
|
||
// TestCompactionPausesWhenWindowTooSmall covers the user report: a tool output
|
||
// that alone exceeds the trigger used to make every "continue" turn re-compact
|
||
// forever. The stuck guard now caps it — at most two compactions, then a paused
|
||
// notice — instead of looping turn after turn.
|
||
func TestCompactionPausesWhenWindowTooSmall(t *testing.T) {
|
||
// One fat_read result (~1750 tok) exceeds the 0.8×1600 trigger on its own.
|
||
perTurn, paused, _ := compactionsPerTurn(t, 1600, strings.Repeat("LARGE FILE CONTENTS. ", 350), "", 8)
|
||
|
||
total := 0
|
||
for _, n := range perTurn {
|
||
total += n
|
||
}
|
||
t.Logf("compactions per turn: %v (total %d), paused=%v", perTurn, total, paused)
|
||
|
||
if total > 2 {
|
||
t.Errorf("compacted %d times; the stuck guard should cap it at ≤2, not loop", total)
|
||
}
|
||
if !paused {
|
||
t.Errorf("expected an auto-compaction-paused notice")
|
||
}
|
||
}
|
||
|
||
// TestCompactionHealthyWindowNeverLoops is the companion: when growth comes from
|
||
// assistant text (which pruning never touches), compaction still fires as the
|
||
// session grows but reclaims enough headroom that it never fires on consecutive
|
||
// turns and never trips the stuck guard.
|
||
func TestCompactionHealthyWindowNeverLoops(t *testing.T) {
|
||
perTurn, paused, _ := compactionsPerTurn(t, 40000, "small tool output", strings.Repeat("analysis paragraph. ", 600), 20)
|
||
|
||
total := 0
|
||
for _, n := range perTurn {
|
||
total += n
|
||
}
|
||
t.Logf("compactions per turn: %v (total %d), paused=%v", perTurn, total, paused)
|
||
|
||
if paused {
|
||
t.Errorf("a healthy window should never pause auto-compaction")
|
||
}
|
||
if total == 0 {
|
||
t.Errorf("expected compaction to fire at least once over a long session")
|
||
}
|
||
if c := consecutiveCompactingTurns(perTurn); c > 1 {
|
||
t.Errorf("compaction fired on %d consecutive turns; a healthy compaction should leave breathing room", c)
|
||
}
|
||
}
|
||
|
||
// TestPruneKeepsToolHeavySessionBounded: when growth comes from tool results,
|
||
// pruning alone keeps the prompt under the trigger for the whole session — the
|
||
// paid summarize call never happens and the stuck guard never trips. 20 turns of
|
||
// ~3k-token blobs would otherwise cross 0.8×40000 around turn 11.
|
||
func TestPruneKeepsToolHeavySessionBounded(t *testing.T) {
|
||
perTurn, paused, prunes := compactionsPerTurn(t, 40000, strings.Repeat("file line. ", 1100), "", 20)
|
||
|
||
total := 0
|
||
for _, n := range perTurn {
|
||
total += n
|
||
}
|
||
t.Logf("compactions per turn: %v (total %d), paused=%v, prunes=%d", perTurn, total, paused, prunes)
|
||
|
||
if total != 0 {
|
||
t.Errorf("compaction fired %d times; pruning should keep a tool-heavy session bounded without folding", total)
|
||
}
|
||
if paused {
|
||
t.Errorf("auto-compaction paused; pruning should have prevented the stuck loop entirely")
|
||
}
|
||
if prunes == 0 {
|
||
t.Errorf("expected at least one prune pass over a tool-heavy session")
|
||
}
|
||
}
|