Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

393 lines
13 KiB
Go

package agent
import (
"context"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/zzet/gortex/internal/llm"
)
// stubSummarizer is a controllable llm.Provider used as a rolling-summary
// summarizer. It records how many times Complete was called, returns a fixed
// short summary with a fixed usage, and can optionally block until released
// (to exercise the async drop-after-cancel path).
type stubSummarizer struct {
calls int32
summary string
usage llm.TokenUsage
block chan struct{} // when non-nil, Complete waits on it (or ctx) before returning
started chan struct{} // closed-once signal that Complete has begun (optional)
once sync.Once
}
func (s *stubSummarizer) Name() string { return "stub-summarizer" }
func (s *stubSummarizer) Close() error { return nil }
func (s *stubSummarizer) Complete(ctx context.Context, _ llm.CompletionRequest) (llm.CompletionResponse, error) {
atomic.AddInt32(&s.calls, 1)
if s.started != nil {
s.once.Do(func() { close(s.started) })
}
if s.block != nil {
select {
case <-s.block:
case <-ctx.Done():
return llm.CompletionResponse{Usage: s.usage}, ctx.Err()
}
}
return llm.CompletionResponse{Text: s.summary, Usage: s.usage}, nil
}
func (s *stubSummarizer) callCount() int { return int(atomic.LoadInt32(&s.calls)) }
// buildConv assembles a conversation: pinned system + first user task,
// followed by `rounds` assistant/tool round pairs whose content is `filler`.
func buildConv(rounds int, filler string) []llm.Message {
conv := []llm.Message{
{Role: llm.RoleSystem, Content: "SYSTEM PROMPT"},
{Role: llm.RoleUser, Content: "the original task"},
}
for i := 0; i < rounds; i++ {
conv = append(conv,
llm.Message{Role: llm.RoleAssistant, Content: `{"tool":"search","args":{}} ` + filler},
llm.Message{Role: llm.RoleTool, Content: "tool result " + filler, ToolName: "search"},
)
}
return conv
}
func TestPartitionZones_SplitsAndPins(t *testing.T) {
// 10 rounds, ActiveRounds=4 → last 4 rounds (8 msgs) active, the
// remaining 6 rounds (12 msgs) compress, nothing frozen yet.
conv := buildConv(10, "x")
frozen, compress, active := partitionZones(conv, 4)
if len(frozen) != 0 {
t.Errorf("frozen = %d msgs, want 0 (no prior summary)", len(frozen))
}
if got := countRounds(compress); got != 6 {
t.Errorf("compress rounds = %d, want 6", got)
}
if got := countRounds(active); got != 4 {
t.Errorf("active rounds = %d, want 4", got)
}
// The pinned system + first user task are not in any zone.
for _, m := range append(append(append([]llm.Message{}, frozen...), compress...), active...) {
if m.Role == llm.RoleSystem {
t.Errorf("system prompt leaked into a zone")
}
if m.Role == llm.RoleUser && m.Content == "the original task" {
t.Errorf("first user task leaked into a zone")
}
}
// active holds the most recent rounds, verbatim.
if active[len(active)-1].Content != conv[len(conv)-1].Content {
t.Errorf("active tail not verbatim: %q vs %q", active[len(active)-1].Content, conv[len(conv)-1].Content)
}
}
func TestPartitionZones_FrozenSummaryStaysFrozen(t *testing.T) {
conv := []llm.Message{
{Role: llm.RoleSystem, Content: "SYSTEM"},
{Role: llm.RoleUser, Content: "task"},
{Role: llm.RoleAssistant, Content: summarySentinel + "earlier steps"},
}
// then 6 verbatim rounds
conv = append(conv, buildConv(6, "y")[2:]...)
frozen, compress, active := partitionZones(conv, 4)
if len(frozen) != 1 || !isFrozenSummary(frozen[0]) {
t.Fatalf("frozen = %v, want exactly the one summary note", frozen)
}
if countRounds(active) != 4 {
t.Errorf("active rounds = %d, want 4", countRounds(active))
}
if countRounds(compress) != 2 {
t.Errorf("compress rounds = %d, want 2", countRounds(compress))
}
}
func TestPartitionZones_ShortConversation(t *testing.T) {
// Only the pinned messages, no rounds.
conv := buildConv(0, "")
frozen, compress, active := partitionZones(conv, 4)
if frozen != nil || compress != nil || active != nil {
t.Errorf("short conv produced zones: f=%v c=%v a=%v", frozen, compress, active)
}
// Fewer rounds than ActiveRounds → everything is active, nothing compressed.
conv = buildConv(2, "z")
_, compress, active = partitionZones(conv, 4)
if compress != nil {
t.Errorf("compress = %v, want nil when rounds < ActiveRounds", compress)
}
if countRounds(active) != 2 {
t.Errorf("active rounds = %d, want 2", countRounds(active))
}
}
func TestMaybeCompact_SyncReducesBelowLowWater(t *testing.T) {
// A big conversation: 12 rounds of long filler so it crosses the trigger.
filler := strings.Repeat("lorem ipsum dolor sit amet ", 40)
conv := buildConv(12, filler)
c := NewRollingCompactor(&stubSummarizer{
summary: "compact summary of earlier work",
usage: llm.TokenUsage{InputTokens: 500, OutputTokens: 20},
}, 4, 3000, 100)
c.sync = true
pre := estimateConvTokens(conv, llm.TokenUsage{})
if pre < c.CompressTriggerTokens {
t.Fatalf("setup: pre=%d should exceed trigger=%d", pre, c.CompressTriggerTokens)
}
var usage llm.TokenUsage
var mu sync.Mutex
out, did := c.maybeCompact(context.Background(), conv, pre, &usage, &mu)
if !did {
t.Fatal("maybeCompact did not compact a conversation over the high-water mark")
}
post := estimateConvTokens(out, llm.TokenUsage{})
if post >= pre {
t.Errorf("post-compaction size %d not below pre %d", post, pre)
}
// The pinned system + first user task survive; exactly one frozen summary added.
if out[0].Role != llm.RoleSystem || out[1].Content != "the original task" {
t.Errorf("pinned messages not preserved: %+v", out[:2])
}
summaries := 0
for _, m := range out {
if isFrozenSummary(m) {
summaries++
}
}
if summaries != 1 {
t.Errorf("frozen summaries = %d, want 1", summaries)
}
// The last ActiveRounds rounds are byte-identical to the original tail.
for i := 1; i <= c.ActiveRounds*2; i++ {
if out[len(out)-i].Content != conv[len(conv)-i].Content {
t.Errorf("active tail msg -%d changed: %q vs %q", i, out[len(out)-i].Content, conv[len(conv)-i].Content)
}
}
// The summarizer's usage was attributed.
if usage.InputTokens != 500 || usage.OutputTokens != 20 {
t.Errorf("summarizer usage not attributed: got %+v", usage)
}
}
func TestMaybeCompact_BelowThresholdUntouched(t *testing.T) {
conv := buildConv(3, "short")
stub := &stubSummarizer{summary: "x"}
c := NewRollingCompactor(stub, 4, 100000, 100)
var usage llm.TokenUsage
var mu sync.Mutex
size := estimateConvTokens(conv, llm.TokenUsage{})
out, did := c.maybeCompact(context.Background(), conv, size, &usage, &mu)
if did {
t.Error("compacted a conversation under the trigger")
}
if len(out) != len(conv) {
t.Errorf("conversation length changed: %d vs %d", len(out), len(conv))
}
if stub.callCount() != 0 {
t.Errorf("summarizer called %d times for a short conversation, want 0", stub.callCount())
}
}
func TestMaybeCompact_NilSummarizerIsNoOp(t *testing.T) {
conv := buildConv(20, strings.Repeat("big ", 100))
c := NewRollingCompactor(nil, 4, 10, 5) // nil summarizer disables compaction
var usage llm.TokenUsage
var mu sync.Mutex
out, did := c.maybeCompact(context.Background(), conv, 1_000_000, &usage, &mu)
if did {
t.Error("nil-summarizer compactor compacted")
}
if len(out) != len(conv) {
t.Errorf("conversation mutated by a disabled compactor")
}
}
func TestMaybeCompact_LateSummaryDroppedOnCancel(t *testing.T) {
filler := strings.Repeat("lorem ipsum dolor ", 40)
conv := buildConv(12, filler)
block := make(chan struct{})
started := make(chan struct{})
stub := &stubSummarizer{
summary: "should be dropped",
usage: llm.TokenUsage{InputTokens: 99, OutputTokens: 3},
block: block,
started: started,
}
c := NewRollingCompactor(stub, 4, 3000, 100) // async (sync=false)
ctx, cancel := context.WithCancel(context.Background())
var usage llm.TokenUsage
var mu sync.Mutex
pre := estimateConvTokens(conv, llm.TokenUsage{})
// First call spawns the background summarizer and returns unchanged.
out, did := c.maybeCompact(ctx, conv, pre, &usage, &mu)
if did {
t.Fatal("async first call should not compact synchronously")
}
if len(out) != len(conv) {
t.Fatal("async first call mutated the conversation")
}
<-started // the summarizer goroutine is running
cancel() // cancel runCtx — mirrors Run's defer cancel()
close(block) // let the summarizer finish; it must observe the cancelled ctx
c.wait() // drain the goroutine (what Run does)
// A late summary must be dropped: nothing is left ready to splice, and
// the conversation is unchanged. (Inspect the compactor state directly so
// this assertion does not spawn a fresh concurrent summarizer.)
c.pendingMu.Lock()
ready := c.ready
c.pendingMu.Unlock()
if ready {
t.Error("a summary produced after ctx-cancel was left ready to splice (should be dropped)")
}
// applyReadySummary on a drained compactor returns the conversation
// unchanged, with no panic.
out2, did2 := c.applyReadySummary(conv)
if did2 {
t.Error("a dropped summary was spliced")
}
if len(out2) != len(conv) {
t.Error("conversation changed despite a dropped summary")
}
// Even a dropped summary's tokens are attributed — no unaccounted spend.
// Read under mu; the goroutine has been drained by c.wait().
mu.Lock()
inTok, outTok := usage.InputTokens, usage.OutputTokens
mu.Unlock()
if inTok != 99 || outTok != 3 {
t.Errorf("dropped-summary usage not attributed: got in=%d out=%d", inTok, outTok)
}
}
func TestMaybeCompact_AsyncSummarySplicedNextTurn(t *testing.T) {
filler := strings.Repeat("lorem ipsum dolor ", 40)
conv := buildConv(12, filler)
stub := &stubSummarizer{
summary: "async summary text",
usage: llm.TokenUsage{InputTokens: 10, OutputTokens: 2},
}
c := NewRollingCompactor(stub, 4, 3000, 100) // async
var usage llm.TokenUsage
var mu sync.Mutex
pre := estimateConvTokens(conv, llm.TokenUsage{})
_, did := c.maybeCompact(context.Background(), conv, pre, &usage, &mu)
if did {
t.Fatal("first async call should not compact synchronously")
}
c.wait() // let the (non-blocking) summarizer finish
out, did2 := c.maybeCompact(context.Background(), conv, pre, &usage, &mu)
if !did2 {
t.Fatal("a ready async summary was not spliced on the next turn")
}
found := false
for _, m := range out {
if isFrozenSummary(m) && strings.Contains(m.Content, "async summary text") {
found = true
}
}
if !found {
t.Error("spliced conversation missing the async summary note")
}
if usage.InputTokens != 10 {
t.Errorf("async summarizer usage not attributed: %+v", usage)
}
}
func TestEstimateConvTokens_PrefersLastUsage(t *testing.T) {
conv := buildConv(2, "some content here")
// With a nonzero lastUsage prompt count, it wins.
if got := estimateConvTokens(conv, llm.TokenUsage{InputTokens: 4242}); got != 4242 {
t.Errorf("estimateConvTokens = %d, want 4242 (lastUsage preferred)", got)
}
// With zero usage it falls back to counting message text (nonzero).
if got := estimateConvTokens(conv, llm.TokenUsage{}); got <= 0 {
t.Errorf("estimateConvTokens fallback = %d, want > 0", got)
}
}
// TestRun_LongLoopBounded drives a real Agent.Run with a tool provider that
// never finalizes, so the loop runs to maxSteps, and asserts that a compactor
// keeps the conversation bounded while a short run is left untouched.
func TestRun_CompactionWiredIntoRun(t *testing.T) {
// A provider that always emits a (looping-distinct) tool call so the
// agent keeps appending rounds until maxSteps.
tp := &toolLoopProvider{}
summ := &stubSummarizer{
summary: "bounded",
usage: llm.TokenUsage{InputTokens: 7, OutputTokens: 1},
}
comp := NewRollingCompactor(summ, 2, 200, 50)
comp.sync = true // deterministic in-test compaction
ag, err := New(tp, []Tool{{
Name: "noop",
Description: "does nothing",
Run: func(map[string]any) (string, error) { return strings.Repeat("padding ", 80), nil },
}}, WithCompactor(comp))
if err != nil {
t.Fatal(err)
}
_, _, runErr := ag.Run(context.Background(), "", "do the long thing", 12)
if runErr == nil {
t.Fatal("expected the non-finalizing loop to exhaust maxSteps")
}
if summ.callCount() == 0 {
t.Error("compactor never summarized during a long bounded loop")
}
// The summarizer's spend is folded into LastUsage.
if ag.LastUsage().InputTokens < 7 {
t.Errorf("summarizer usage not folded into LastUsage: %+v", ag.LastUsage())
}
}
// toolLoopProvider emits a distinct tool call every step (varying args so the
// loop-detector does not short-circuit), never final_answer.
type toolLoopProvider struct{ n int }
func (p *toolLoopProvider) Name() string { return "loop" }
func (p *toolLoopProvider) Close() error { return nil }
func (p *toolLoopProvider) Complete(_ context.Context, _ llm.CompletionRequest) (llm.CompletionResponse, error) {
p.n++
return llm.CompletionResponse{
Text: `{"tool":"noop","args":{"i":` + itoa(p.n) + `}}`,
Usage: llm.TokenUsage{InputTokens: 50, OutputTokens: 5},
}, nil
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}