package runtime // This file implements the faux provider (mirrors pi providers/faux.ts) and the // loop integration tests that drive the whole agent loop through it — the // project's primary and only core test seam (US-002 / Testing Decisions, #16). // // Unlike loop_test.go, which drives the loop with a coarse StreamFn that emits // only a terminal StreamDoneEvent, the faux provider is a real Provider whose // StreamCompletion replays a *fine-grained* script of AssistantMessageEvents // (start → text/toolcall deltas → done) — one scripted turn per call. It is // wired into the loop via StreamFnFromProvider, the real seam, so the whole // path (message_start / message_update / message_end deltas, the six hooks, // truncation protection, parallel ordering, and EventStream cancellation) is // covered end to end without mocking any loop-internal function. import ( "context" "encoding/json" "sync" "testing" "time" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/agenttool" "github.com/smallnest/pigo/internal/provider" ) // fauxTurn is one scripted assistant turn: the fine-grained stream events the // faux provider replays for a single StreamCompletion call. type fauxTurn []provider.AssistantMessageEvent // textTurn scripts a turn that streams text as start → text delta → done(end_turn). func textTurn(text string) fauxTurn { partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant} withText := partial withText.Content = agentcore.ContentList{agentcore.NewTextContent(text)} final := withText final.StopReason = agentcore.StopReasonEndTurn return fauxTurn{ provider.StreamStartEvent{Partial: partial}, provider.StreamTextEvent{Partial: withText}, provider.StreamDoneEvent{Message: final}, } } // toolCallTurn scripts a turn that streams one tool call as // start → toolcall delta → done(tool_use). func toolCallTurn(id, name, args string) fauxTurn { partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant} withCall := partial withCall.Content = agentcore.ContentList{agentcore.NewToolCallContent(id, name, json.RawMessage(args))} final := withCall final.StopReason = agentcore.StopReasonToolUse return fauxTurn{ provider.StreamStartEvent{Partial: partial}, provider.StreamToolCallEvent{Partial: withCall}, provider.StreamDoneEvent{Message: final}, } } // fauxProvider is a real Provider that replays one scripted turn per // StreamCompletion call, in order. It records every request it received so // tests can assert what the loop actually sent (model, context, config). Once // the script is exhausted it replays a plain end_turn turn. type fauxProvider struct { name string models []provider.Model turns []fauxTurn mu sync.Mutex calls int requests []provider.CompletionRequest // delay optionally slows each delta emit, used by the cancellation test to // keep the stream open long enough to cancel mid-flight. delay time.Duration } func (p *fauxProvider) Name() string { return p.name } func (p *fauxProvider) Models() []provider.Model { return p.models } func (p *fauxProvider) callCount() int { p.mu.Lock() defer p.mu.Unlock() return p.calls } func (p *fauxProvider) requestAt(i int) provider.CompletionRequest { p.mu.Lock() defer p.mu.Unlock() return p.requests[i] } func (p *fauxProvider) StreamCompletion(ctx context.Context, req provider.CompletionRequest) (*provider.AssistantMessageEventStream, error) { p.mu.Lock() idx := p.calls p.calls++ p.requests = append(p.requests, req) var turn fauxTurn if idx < len(p.turns) { turn = p.turns[idx] } else { turn = textTurn("") } delay := p.delay p.mu.Unlock() s := provider.NewAssistantMessageEventStream(0) go func() { for _, ev := range turn { if delay > 0 { select { case <-time.After(delay): case <-ctx.Done(): s.SetError(ctx.Err()) s.Close() return } } if err := s.Emit(ctx, ev); err != nil { s.SetError(err) s.Close() return } } s.Close() }() return s, nil } // newFauxRunCfg wires a faux provider into the loop via StreamFnFromProvider // (the real seam) and registers the given tools. No loop-internal function is // mocked — only the provider boundary. func newFauxRunCfg(p *fauxProvider, tools ...agentcore.AgentTool) RunConfig { reg := agenttool.NewToolRegistry() for _, tl := range tools { _ = reg.Register(tl) } return RunConfig{ LoopConfig: LoopConfig{Model: "faux", Stream: provider.StreamFnFromProvider(p)}, Batch: agenttool.BatchConfig{ToolExecutorConfig: agenttool.ToolExecutorConfig{Registry: reg}}, } } // TestFauxProviderTextToolText drives the flagship seam scenario end to end: // text → tool call → text over the real loop, asserting both the AgentEvent // stream shape and the final []AgentMessage. Nothing loop-internal is mocked. func TestFauxProviderTextToolText(t *testing.T) { p := &fauxProvider{ name: "faux", models: []provider.Model{{Provider: "faux", ID: "faux"}}, turns: []fauxTurn{ textTurn("thinking about it"), // turn 1: plain text, no tool toolCallTurn("call-1", "echo", `{"msg":"hello"}`), // turn 2: tool call textTurn("all done"), // turn 3: final text }, } // GetFollowUpMessages injects a follow-up once so the loop advances past the // first natural (text-only) turn end into the tool-call turn. served := false cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false)) cfg.GetFollowUpMessages = func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage { if served { return nil } served = true return []agentcore.AgentMessage{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("go on")}}} } agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("start")}}}} kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg)) // Event shape: message deltas must appear (start/update/end), a tool // executed exactly once, and the run bookended by agent_start/agent_end. if kinds[0] != agentcore.EventAgentStart || kinds[len(kinds)-1] != agentcore.EventAgentEnd { t.Fatalf("run must be bracketed by agent_start/agent_end, got %v", kinds) } if countKind(kinds, agentcore.EventMessageStart) < 3 || countKind(kinds, agentcore.EventMessageEnd) < 3 { t.Errorf("expected fine-grained message deltas for each turn, got %v", kinds) } if countKind(kinds, agentcore.EventMessageUpdate) < 3 { t.Errorf("expected message_update deltas (text/toolcall), got %v", kinds) } if got := countKind(kinds, agentcore.EventToolExecutionStart); got != 1 { t.Errorf("expected 1 tool_execution_start, got %d in %v", got, kinds) } if got := countKind(kinds, agentcore.EventToolExecutionEnd); got != 1 { t.Errorf("expected 1 tool_execution_end, got %d in %v", got, kinds) } if got := countKind(kinds, agentcore.EventTurnStart); got != 3 { t.Errorf("expected 3 turns (text→tool→text), got %d in %v", got, kinds) } // Final messages: assistant(text) + user(follow-up) + assistant(tool) + // toolResult + assistant(text) = 5, in order. if len(msgs) != 5 { t.Fatalf("expected 5 new messages, got %d: %+v", len(msgs), msgs) } if a, ok := msgs[0].(agentcore.AssistantMessage); !ok || textContentOf(a.Content) != "thinking about it" { t.Errorf("msg[0] should be the first text assistant message, got %T %+v", msgs[0], msgs[0]) } if _, ok := msgs[1].(agentcore.UserMessage); !ok { t.Errorf("msg[1] should be the injected follow-up user message, got %T", msgs[1]) } if a, ok := msgs[2].(agentcore.AssistantMessage); !ok || len(a.ToolCalls()) != 1 { t.Errorf("msg[2] should be the tool-call assistant message, got %T %+v", msgs[2], msgs[2]) } tr, ok := msgs[3].(agentcore.ToolResultMessage) if !ok || tr.ToolCallID != "call-1" || tr.IsError { t.Errorf("msg[3] should be the successful echo tool result, got %T %+v", msgs[3], msgs[3]) } if a, ok := msgs[4].(agentcore.AssistantMessage); !ok || textContentOf(a.Content) != "all done" { t.Errorf("msg[4] should be the final text assistant message, got %T %+v", msgs[4], msgs[4]) } // The loop must have driven the provider exactly three times, each carrying // the growing context and the configured model. if p.callCount() != 3 { t.Fatalf("provider called %d times, want 3", p.callCount()) } if req := p.requestAt(0); req.Model != "faux" { t.Errorf("provider request model = %q, want faux", req.Model) } } // textContentOf returns the concatenated text of a content list. func textContentOf(list agentcore.ContentList) string { var s string for _, c := range list { if tc, ok := c.(agentcore.TextContent); ok { s += tc.Text } } return s } // TestFauxSeamSixHooks exercises all six loop hooks through the real seam in a // single run: the two per-request LoopConfig hooks (TransformContext, // ConvertToLlm resolved via GetAPIKey) and the four RunConfig hooks // (GetFollowUpMessages, GetSteeringMessages, PrepareNextTurn, // ShouldStopAfterTurn). Each hook records that it fired and, where observable, // that its effect reached the provider request. func TestFauxSeamSixHooks(t *testing.T) { p := &fauxProvider{ name: "faux", models: []provider.Model{{Provider: "faux", ID: "faux"}}, turns: []fauxTurn{ toolCallTurn("call-1", "echo", `{}`), // turn 1: tool → afterTurn hooks fire textTurn("second"), // turn 2: end (after model swap) }, } var fired struct { transform, convert, apiKey, followUp, steering, prepare, shouldStop bool } swapped := "swapped-model" cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false)) cfg.Provider = "faux" cfg.TransformContext = func(ctx context.Context, msgs agentcore.MessageList) agentcore.MessageList { fired.transform = true return msgs } cfg.ConvertToLlm = func(msgs agentcore.MessageList) agentcore.MessageList { fired.convert = true return msgs } cfg.GetAPIKey = func(ctx context.Context, provider string) string { fired.apiKey = true return "dyn-key" } cfg.GetSteeringMessages = func(ctx context.Context) []agentcore.AgentMessage { fired.steering = true return nil } cfg.PrepareNextTurn = func(ctx context.Context, agentCtx *agentcore.AgentContext) *TurnUpdate { fired.prepare = true return &TurnUpdate{Model: &swapped} } stopCalls := 0 cfg.ShouldStopAfterTurn = func(ctx context.Context, agentCtx *agentcore.AgentContext) bool { fired.shouldStop = true stopCalls++ return false // never stop early; let the run end naturally } cfg.GetFollowUpMessages = func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage { fired.followUp = true // No follow-up: the tool-call turn already drives turn 2, so the run // ends naturally after the second turn. return nil } agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("hi")}}}} collectStream(t, agentLoop(context.Background(), agentCtx, cfg)) if !fired.transform || !fired.convert || !fired.apiKey { t.Errorf("per-request hooks not all fired: %+v", fired) } if !fired.followUp || !fired.steering || !fired.prepare || !fired.shouldStop { t.Errorf("per-turn hooks not all fired: %+v", fired) } if stopCalls == 0 { t.Error("ShouldStopAfterTurn was never consulted") } // GetAPIKey's dynamic key must have reached the provider request config. if got := p.requestAt(0).Config.APIKey; got != "dyn-key" { t.Errorf("GetAPIKey result not threaded to provider, APIKey = %q", got) } // PrepareNextTurn swapped the model before turn 2. if p.callCount() >= 2 { if got := p.requestAt(1).Config.APIKey; got != "dyn-key" { t.Errorf("turn 2 APIKey = %q, want dyn-key", got) } if got := p.requestAt(1).Model; got != swapped { t.Errorf("PrepareNextTurn model swap not applied, turn 2 model = %q, want %q", got, swapped) } } } // TestFauxSeamTruncationProtection verifies that a truncated (stopReason=length) // tool-call turn is protected: the tool is NOT executed and a synthesized failed // tool result is fed back, all through the seam. func TestFauxSeamTruncationProtection(t *testing.T) { // Turn 1: a tool call that arrives truncated. Turn 2: end. truncPartial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{agentcore.NewToolCallContent("t1", "echo", json.RawMessage(`{}`))}} truncFinal := truncPartial truncFinal.StopReason = agentcore.StopReasonLength p := &fauxProvider{ name: "faux", turns: []fauxTurn{ { provider.StreamStartEvent{Partial: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}}, provider.StreamToolCallEvent{Partial: truncPartial}, provider.StreamDoneEvent{Message: truncFinal}, }, textTurn("recovered"), }, } cfg := newFauxRunCfg(p, echoTool("echo", agentcore.ToolExecutionParallel, false)) agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}} kinds, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg)) if countKind(kinds, agentcore.EventToolExecutionEnd) != 0 { t.Errorf("truncated tool call must not execute, got %v", kinds) } var foundFail bool for _, m := range msgs { if tr, ok := m.(agentcore.ToolResultMessage); ok && tr.IsError && tr.ToolCallID == "t1" { foundFail = true } } if !foundFail { t.Errorf("expected a synthesized failed tool result for the truncated call, got %+v", msgs) } } // TestFauxSeamParallelOrderingPreserved verifies that a turn with multiple // parallel tool calls yields tool results in source order regardless of which // tool finishes first, driven through the seam. func TestFauxSeamParallelOrderingPreserved(t *testing.T) { // One assistant turn with three tool calls in a fixed order; the tools sleep // in reverse so completion order differs from source order. partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant, Content: agentcore.ContentList{ agentcore.NewToolCallContent("a0", "slow", json.RawMessage(`{}`)), agentcore.NewToolCallContent("a1", "mid", json.RawMessage(`{}`)), agentcore.NewToolCallContent("a2", "fast", json.RawMessage(`{}`)), }} final := partial final.StopReason = agentcore.StopReasonToolUse p := &fauxProvider{ turns: []fauxTurn{ {provider.StreamStartEvent{Partial: agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}}, provider.StreamToolCallEvent{Partial: partial}, provider.StreamDoneEvent{Message: final}}, textTurn("done"), }, } mk := func(name string, delay time.Duration) execTool { return execTool{ name: name, mode: agentcore.ToolExecutionParallel, run: func(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) { time.Sleep(delay) return agentcore.AgentToolResult{Content: agentcore.ContentList{agentcore.NewTextContent(name)}}, nil }, } } cfg := newFauxRunCfg(p, mk("slow", 25*time.Millisecond), mk("mid", 12*time.Millisecond), mk("fast", 1*time.Millisecond)) agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}} _, msgs := collectStream(t, agentLoop(context.Background(), agentCtx, cfg)) var order []string for _, m := range msgs { if tr, ok := m.(agentcore.ToolResultMessage); ok { order = append(order, tr.ToolCallID) } } want := []string{"a0", "a1", "a2"} if len(order) != 3 || order[0] != want[0] || order[1] != want[1] || order[2] != want[2] { t.Errorf("parallel tool results out of source order: got %v, want %v", order, want) } } // TestFauxSeamStreamCancellation verifies that cancelling the context stops the // run: the consumer stops receiving events and Result reports the cancellation, // exercised through the seam with a provider that streams slowly. func TestFauxSeamStreamCancellation(t *testing.T) { p := &fauxProvider{ turns: []fauxTurn{textTurn("never fully delivered")}, delay: 50 * time.Millisecond, // slow enough to cancel mid-stream } cfg := newFauxRunCfg(p) agentCtx := &agentcore.AgentContext{Messages: agentcore.MessageList{agentcore.UserMessage{RoleField: agentcore.RoleUser}}} ctx, cancel := context.WithCancel(context.Background()) s := agentLoop(ctx, agentCtx, cfg) // Read the first event, then cancel while the provider is still streaming. <-s.Events() cancel() // Drain remaining events (must terminate, not hang). for range s.Events() { } if _, err := s.Result(context.Background()); err != nil { // A set result is also acceptable (the run may have finished emitting // agent_end before cancellation propagated); but if an error is set it // must be the cancellation. if err != context.Canceled { t.Errorf("cancelled run result error = %v, want context.Canceled or nil", err) } } }