first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
package runtime
// Tests for auto-compaction wiring into the loop (US-004, #120): when context
// usage exceeds the usable window after a turn settles, runLoop compacts in
// place and emits a CompactionEvent; a compaction failure is non-fatal and is
// reported via a CompactionEvent carrying an error while the original context is
// preserved.
import (
"context"
"errors"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/compaction"
"github.com/smallnest/pigo/internal/provider"
)
// bigUserMessages returns n user messages each carrying `chars` characters, used
// to inflate estimated context tokens past a small window.
func bigUserMessages(n, chars int) agentcore.MessageList {
body := strings.Repeat("x", chars)
msgs := make(agentcore.MessageList, 0, n)
for i := 0; i < n; i++ {
msgs = append(msgs, agentcore.UserMessage{
RoleField: agentcore.RoleUser,
Content: agentcore.ContentList{agentcore.NewTextContent(body)},
})
}
return msgs
}
// findCompaction returns the first CompactionEvent emitted, or nil.
func findCompaction(events []agentcore.AgentEvent) *agentcore.CompactionEvent {
for _, ev := range events {
if c, ok := ev.(agentcore.CompactionEvent); ok {
return &c
}
}
return nil
}
// collectEvents drains a stream returning the concrete events (not just kinds).
func collectEvents(t *testing.T, s *LoopEventStream) []agentcore.AgentEvent {
t.Helper()
var out []agentcore.AgentEvent
for ev := range s.Events() {
out = append(out, ev)
}
if _, err := s.Result(context.Background()); err != nil {
t.Fatalf("stream result: %v", err)
}
return out
}
// summaryStream yields a fixed summary text as an end_turn assistant message,
// standing in for the summarization model.
func summaryStream(text string) provider.StreamFn {
return func(ctx context.Context, model string, llm provider.LlmContext, cfg provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
msg := agentcore.AssistantMessage{
RoleField: agentcore.RoleAssistant,
StopReason: agentcore.StopReasonEndTurn,
Content: agentcore.ContentList{agentcore.NewTextContent(text)},
}
s := provider.NewAssistantMessageEventStream(0)
go func() { _ = s.Emit(ctx, provider.StreamDoneEvent{Message: msg}); s.Close() }()
return s, nil
}
}
func TestAutoCompactionFiresOnThreshold(t *testing.T) {
// Main stream just ends the turn with text; the summary stream is separate so
// the summarization does not consume main-stream scripted turns.
main := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ok")}},
})
cfg := newRunCfg(main)
cfg.SummaryStream = summaryStream("## Goal\ncompacted")
// Small window + reserve so a handful of fat messages exceed the threshold.
cfg.ContextWindow = 2000
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
// Seed a long history so EstimateContextTokens > window-reserve.
agentCtx := &agentcore.AgentContext{Messages: bigUserMessages(12, 800)}
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
ce := findCompaction(events)
if ce == nil {
t.Fatalf("expected a CompactionEvent, got events %+v", events)
}
// A CompactionStartEvent must precede the CompactionEvent so a front-end can
// show an in-progress indicator while summarization is in flight.
var startedBefore bool
for _, ev := range events {
if _, ok := ev.(agentcore.CompactionStartEvent); ok {
startedBefore = true
}
if _, ok := ev.(agentcore.CompactionEvent); ok {
if !startedBefore {
t.Errorf("CompactionStartEvent must be emitted before CompactionEvent")
}
break
}
}
if !startedBefore {
t.Errorf("expected a CompactionStartEvent, got events %+v", events)
}
if ce.ErrorMessage != "" {
t.Fatalf("compaction should have succeeded, got error %q", ce.ErrorMessage)
}
if ce.TokensAfter >= ce.TokensBefore {
t.Errorf("compaction should reduce tokens: before=%d after=%d", ce.TokensBefore, ce.TokensAfter)
}
if ce.SummarizedCount <= 0 {
t.Errorf("expected some messages summarized, got %d", ce.SummarizedCount)
}
// The context must now begin with a compaction checkpoint.
if len(agentCtx.Messages) == 0 || agentCtx.Messages[0].Role() != agentcore.RoleCompaction {
t.Errorf("context should start with a compaction checkpoint, got %+v", agentCtx.Messages)
}
}
func TestAutoCompactionDisabledWhenWindowUnknown(t *testing.T) {
main := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ok")}},
})
cfg := newRunCfg(main)
cfg.SummaryStream = summaryStream("unused")
cfg.ContextWindow = 0 // unknown → disabled
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
agentCtx := &agentcore.AgentContext{Messages: bigUserMessages(12, 800)}
before := len(agentCtx.Messages)
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
if ce := findCompaction(events); ce != nil {
t.Fatalf("no compaction expected when window unknown, got %+v", ce)
}
// Context grows by one assistant reply only (no checkpoint replacement).
if len(agentCtx.Messages) != before+1 {
t.Errorf("context should be untouched by compaction, got %d messages", len(agentCtx.Messages))
}
}
func TestCompactionEventEnvelope(t *testing.T) {
env := eventEnvelope(agentcore.CompactionEvent{
Reason: "threshold",
TokensBefore: 1000,
TokensAfter: 400,
SummarizedCount: 8,
KeptCount: 3,
})
if env["type"] != agentcore.EventCompaction {
t.Errorf("type = %v, want %q", env["type"], agentcore.EventCompaction)
}
if env["tokensBefore"] != 1000 || env["tokensAfter"] != 400 {
t.Errorf("token fields wrong: %+v", env)
}
if env["summarizedCount"] != 8 || env["keptCount"] != 3 {
t.Errorf("count fields wrong: %+v", env)
}
if _, hasErr := env["error"]; hasErr {
t.Errorf("no error key expected on success: %+v", env)
}
// Failure envelope carries the error.
failEnv := eventEnvelope(agentcore.CompactionEvent{Reason: "threshold", ErrorMessage: "boom"})
if failEnv["error"] != "boom" {
t.Errorf("error key expected on failure, got %+v", failEnv)
}
}
func TestAutoCompactionFailureIsNonFatal(t *testing.T) {
main := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ok")}},
})
cfg := newRunCfg(main)
// Summary stream that fails to build: forces compaction.Compact to error.
cfg.SummaryStream = func(ctx context.Context, model string, llm provider.LlmContext, c provider.StreamConfig) (*provider.AssistantMessageEventStream, error) {
return nil, errors.New("summarizer down")
}
cfg.ContextWindow = 2000
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
agentCtx := &agentcore.AgentContext{Messages: bigUserMessages(12, 800)}
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
ce := findCompaction(events)
if ce == nil {
t.Fatalf("expected a CompactionEvent reporting the failure")
}
if ce.ErrorMessage == "" {
t.Errorf("failed compaction must carry an ErrorMessage")
}
if ce.TokensAfter != ce.TokensBefore {
t.Errorf("on failure tokens must be unchanged: before=%d after=%d", ce.TokensBefore, ce.TokensAfter)
}
// The run must still end normally.
if events[len(events)-1].EventType() != agentcore.EventAgentEnd {
t.Errorf("run must end with agent_end despite compaction failure")
}
// No compaction checkpoint should have been inserted.
if len(agentCtx.Messages) > 0 && agentCtx.Messages[0].Role() == agentcore.RoleCompaction {
t.Errorf("failed compaction must not insert a checkpoint")
}
}