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
@@ -0,0 +1,149 @@
package runtime
// End-to-end robustness verification scenarios (US-007 / FR-10): the harness's
// two self-protection mechanisms must fire correctly when driven through the
// real provider seam with NO external LLM.
//
// - LONG SESSION: a run whose accumulated context exceeds the usable window
// (ContextWindow ReserveTokens) must auto-compact in place, emitting a
// successful CompactionEvent, and still finish normally.
// - LARGE OUTPUT: a tool that returns far more than the executor-layer byte
// budget (toolResultMaxBytes = 100_000) must have its result truncated with
// the shared "[truncated" annotation, so a single fat tool result cannot
// overflow the model context, and the run still finishes.
//
// Both scenarios reuse the existing in-repo test infrastructure only: the faux
// provider seam (StreamFnFromProvider via newFauxRunCfg / toolCallTurn / textTurn),
// the scripted StreamFn (scriptedStream / newRunCfg / summaryStream), and the
// event collectors (collectEvents / collectStream / findCompaction). No new
// mocking is invented and no production (non-_test) code is touched.
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/compaction"
)
// TestE2E_LongSession_TriggersCompaction drives a long session whose seeded
// history already exceeds the usable window, forcing the loop to auto-compact
// after the first turn settles. It asserts a *successful* CompactionEvent is
// emitted (empty ErrorMessage), tokens shrink, a compaction checkpoint replaces
// the head of the context, and the run ends normally via agent_end.
func TestE2E_LongSession_TriggersCompaction(t *testing.T) {
// Main stream just ends the turn; a separate summary stream stands in for the
// summarization model so compaction does not consume main-stream turns.
main := scriptedStream([]agentcore.AssistantMessage{
{RoleField: agentcore.RoleAssistant, StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("ack")}},
})
cfg := newRunCfg(main)
cfg.SummaryStream = summaryStream("## Goal\nlong session compacted")
// Deliberately tiny window so a handful of fat seeded messages exceed the
// threshold (ContextWindow ReserveTokens) the moment the first turn settles.
cfg.ContextWindow = 2000
cfg.Compaction = compaction.CompactionSettings{Enabled: true, ReserveTokens: 500, KeepRecentTokens: 100}
// Seed a long history: 16 messages × 800 chars each blows past the usable window.
agentCtx := &agentcore.AgentContext{Messages: bigUserMessages(16, 800)}
events := collectEvents(t, agentLoop(context.Background(), agentCtx, cfg))
ce := findCompaction(events)
if ce == nil {
t.Fatalf("long session must trigger a CompactionEvent, got %v", eventKinds(events))
}
if ce.ErrorMessage != "" {
t.Fatalf("compaction must succeed, got error %q", ce.ErrorMessage)
}
if ce.TokensAfter >= ce.TokensBefore {
t.Errorf("compaction must reduce estimated tokens: before=%d after=%d", ce.TokensBefore, ce.TokensAfter)
}
if ce.SummarizedCount <= 0 {
t.Errorf("compaction must fold at least one message into the summary, got %d", ce.SummarizedCount)
}
// The compacted context must begin with a compaction checkpoint.
if len(agentCtx.Messages) == 0 || agentCtx.Messages[0].Role() != agentcore.RoleCompaction {
t.Errorf("context must start with a compaction checkpoint after compaction, got %+v", agentCtx.Messages)
}
// The run must still terminate cleanly.
if n := len(events); n == 0 || events[n-1].EventType() != agentcore.EventAgentEnd {
t.Errorf("run must end with agent_end, got %v", eventKinds(events))
}
}
// TestE2E_LargeOutput_TriggersTruncation drives a tool call whose tool returns
// output far larger than the executor-layer byte budget. It asserts the
// resulting tool-result content carries the shared "[truncated" annotation and
// is clipped well below the raw size (so the context cannot overflow from a
// single fat result), and the run still completes normally.
func TestE2E_LargeOutput_TriggersTruncation(t *testing.T) {
// A payload well over toolResultMaxBytes (100_000). 250_000 bytes guarantees
// the executor-layer budget bites regardless of any looser inner cap.
const rawSize = 250_000
huge := strings.Repeat("A", rawSize)
// A tool that emits the oversized payload as a single text block.
bigOutputTool := execTool{
name: "flood",
mode: agentcore.ToolExecutionParallel,
run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(huge)}}, nil
},
}
// Turn 1: call the flooding tool. Turn 2: end the turn with text.
p := &fauxProvider{
name: "faux",
turns: []fauxTurn{
toolCallTurn("call-flood", "flood", `{}`),
textTurn("handled large output"),
},
}
cfg := newFauxRunCfg(p, bigOutputTool)
// A generous window: the point is that truncation keeps the result small
// enough that the context does NOT overflow, so no compaction is needed.
cfg.ContextWindow = 200_000
agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("run flood")}}}}
kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg))
// Locate the flood tool result and assert it was truncated.
var floodResult *agentcore.ToolResultMessage
for i := range msgs {
if tr, ok := msgs[i].(agentcore.ToolResultMessage); ok && tr.ToolCallID == "call-flood" {
trCopy := tr
floodResult = &trCopy
}
}
if floodResult == nil {
t.Fatalf("expected a tool result for the flood call, got %+v", msgs)
}
got := textContentOf(floodResult.Content)
if !strings.Contains(got, "[truncated") {
t.Errorf("large tool output must carry the \"[truncated\" annotation, got %d bytes without it", len(got))
}
// The clipped result must be far smaller than the raw payload — the context
// protection actually reduced the size (it must not blow the 100_000 budget
// wildly; allow generous headroom for head+tail+marker).
if len(got) >= rawSize {
t.Errorf("truncation must shrink the result: got %d bytes, raw was %d", len(got), rawSize)
}
if len(got) > 120_000 {
t.Errorf("truncated result should be near the byte budget, got %d bytes", len(got))
}
// Context must not have overflowed: with truncation in place the tiny clipped
// result never crosses the window, so no compaction should have fired.
for _, ev := range kinds {
if ev == agentcore.EventCompaction {
t.Errorf("truncation should keep context under the window; no compaction expected, got kinds %v", kinds)
}
}
// And the run finished cleanly.
if len(kinds) == 0 || kinds[len(kinds)-1] != agentcore.EventAgentEnd {
t.Errorf("run must end with agent_end, got %v", kinds)
}
}