项目文件夹

文件
2026-07-13 13:00:08 +08:00

206 行
6.2 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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")
}
}