first commit
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user