package agent import ( "context" "errors" "io" "testing" "time" "go-micro.dev/v6/ai" "go-micro.dev/v6/flow" "go-micro.dev/v6/store" ) func TestStreamAskEmitsToolEventsAndFinalTokens(t *testing.T) { calls := 0 fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { if opts.ToolHandler == nil { t.Fatal("StreamAsk must configure a tool handler") } calls++ result := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}}) return &ai.Response{ Reply: "planning", Answer: "final answer", ToolCalls: []ai.ToolCall{{ID: "call-1", Name: "echo", Input: map[string]any{"text": "hello"}, Result: result.Content}}, }, nil } defer func() { fakeGen = nil }() a := newTestAgent(Name("streamer"), WithTool("echo", "echo text", nil, func(ctx context.Context, input map[string]any) (string, error) { return input["text"].(string), nil })) stream, err := a.StreamAsk(context.Background(), "say hello") if err != nil { t.Fatalf("StreamAsk: %v", err) } var types []StreamEventType var tokens string var done *Response for { event, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { t.Fatalf("Recv: %v", err) } types = append(types, event.Type) if event.Type == StreamEventToken { tokens += event.Token } if event.Type == StreamEventDone { done = event.Response } } want := []StreamEventType{StreamEventToolStart, StreamEventToolEnd, StreamEventToken, StreamEventToken, StreamEventToken, StreamEventDone} if len(types) != len(want) { t.Fatalf("event types = %v, want %v", types, want) } for i := range want { if types[i] != want[i] { t.Fatalf("event types = %v, want %v", types, want) } } if tokens != "planning final answer" { t.Fatalf("tokens = %q", tokens) } if done == nil || done.Reply != "planning\n\nfinal answer" { t.Fatalf("done response = %#v", done) } if calls != 1 { t.Fatalf("Generate calls = %d, want 1", calls) } } func TestStreamAskCloseCancelsInFlightModelCall(t *testing.T) { started := make(chan struct{}) fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { close(started) <-ctx.Done() return nil, ctx.Err() } defer func() { fakeGen = nil }() a := newTestAgent(Name("stream-cancel")) stream, err := a.StreamAsk(context.Background(), "cancel me") if err != nil { t.Fatalf("StreamAsk: %v", err) } select { case <-started: case <-time.After(time.Second): t.Fatal("model call did not start") } closed := make(chan error, 1) go func() { closed <- stream.Close() }() select { case err := <-closed: if err != nil { t.Fatalf("Close: %v", err) } case <-time.After(time.Second): t.Fatal("Close did not cancel the in-flight stream") } } func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) { _, err := StreamAsk(context.Background(), unsupportedAgent{}, "hello") if err == nil { t.Fatal("StreamAsk helper should reject unsupported implementations") } } func TestAgentStreamUsesProviderStreamingAndRecordsAssistantMemory(t *testing.T) { var sawRequest bool var sawRunInfo bool fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) { sawRequest = true info, ok := ai.RunInfoFrom(ctx) if !ok || info.RunID == "" || info.Agent != "provider-stream" { t.Fatalf("RunInfo = %#v, %v; want provider stream run metadata", info, ok) } sawRunInfo = true if req.Prompt != "stream the answer" { t.Fatalf("Prompt = %q, want stream the answer", req.Prompt) } if len(req.Messages) != 1 || req.Messages[0].Role != "user" || req.Messages[0].Content != "stream the answer" { t.Fatalf("Messages = %#v, want current user turn in memory", req.Messages) } return &sliceStream{chunks: []string{"hel", "lo"}}, nil } defer func() { fakeStream = nil }() a := newTestAgent(Name("provider-stream")) stream, err := a.Stream(context.Background(), "stream the answer") if err != nil { t.Fatalf("Stream: %v", err) } var reply string for { chunk, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { t.Fatalf("Recv: %v", err) } reply += chunk.Reply } if err := stream.Close(); err != nil { t.Fatalf("Close: %v", err) } if !sawRequest { t.Fatal("provider Stream was not called") } if !sawRunInfo { t.Fatal("provider Stream did not receive RunInfo") } if reply != "hello" { t.Fatalf("reply = %q, want hello", reply) } got := a.mem.Messages() if len(got) != 2 || got[0].Role != "user" || got[0].Content != "stream the answer" || got[1].Role != "assistant" || got[1].Content != "hello" { t.Fatalf("memory = %#v, want user turn and streamed assistant reply", got) } } func TestAgentStreamCanceledContextSkipsProviderCallAndMemory(t *testing.T) { calls := 0 fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) { calls++ return &sliceStream{chunks: []string{"late"}}, nil } defer func() { fakeStream = nil }() ctx, cancel := context.WithCancel(context.Background()) cancel() a := newTestAgent(Name("provider-stream-cancel")) _, err := a.Stream(ctx, "do not start") if !errors.Is(err, context.Canceled) { t.Fatalf("Stream error = %v, want context canceled", err) } if calls != 0 { t.Fatalf("provider Stream calls = %d, want 0 after caller cancellation", calls) } if got := a.mem.Messages(); len(got) != 0 { t.Fatalf("memory = %#v, want no recorded canceled stream turn", got) } } func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) { ctx := context.Background() cp := flow.StoreCheckpoint(store.NewStore(), "stream-resume-agent") toolRuns := 0 first := true fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) { if opts.ToolHandler != nil { res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "charge", Input: map[string]any{"order": "42"}}) if res.Content != "charged" { t.Fatalf("tool result = %q, want charged", res.Content) } } if first { first = false return nil, errors.New("stream disconnected after tool") } return &ai.Response{Reply: "finished from streamed checkpoint"}, nil } defer func() { fakeGen = nil }() a := newTestAgent(Name("stream-resume-agent"), WithCheckpoint(cp), WithTool("charge", "charge once", nil, func(context.Context, map[string]any) (string, error) { toolRuns++ return "charged", nil })) stream, err := a.StreamAsk(ctx, "charge order 42") if err != nil { t.Fatalf("StreamAsk: %v", err) } for { _, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { break } } if toolRuns != 1 { t.Fatalf("tool executions after failed StreamAsk = %d, want 1", toolRuns) } runs, err := Pending(ctx, a) if err != nil { t.Fatalf("Pending: %v", err) } if len(runs) != 1 { t.Fatalf("Pending returned %d runs, want 1", len(runs)) } resumed, err := ResumeStreamAsk(ctx, a, runs[0].ID) if err != nil { t.Fatalf("ResumeStreamAsk: %v", err) } var toolEvents int var done *Response for { event, err := resumed.Recv() if errors.Is(err, io.EOF) { break } if err != nil { t.Fatalf("resumed Recv: %v", err) } if event.Type == StreamEventToolStart || event.Type == StreamEventToolEnd { toolEvents++ } if event.Type == StreamEventDone { done = event.Response } } if toolRuns != 1 { t.Fatalf("tool executions after ResumeStreamAsk = %d, want completed tool was not replayed", toolRuns) } if toolEvents != 2 { t.Fatalf("resumed tool events = %d, want start/end for replayed checkpoint result", toolEvents) } if done == nil || done.Reply != "finished from streamed checkpoint" || done.RunID != runs[0].ID { t.Fatalf("done response = %#v", done) } } func TestAgentStreamDoesNotRecordUserWhenProviderStreamingUnsupported(t *testing.T) { fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) { if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" || req.Messages[len(req.Messages)-1].Content != "stream fallback" { t.Fatalf("stream request messages = %+v, want pending user message", req.Messages) } return nil, ai.ErrStreamingUnsupported } defer func() { fakeStream = nil }() mem := NewInMemory(8) a := newTestAgent(Name("stream-fallback"), WithMemory(mem), WithTool("echo", "echo text", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil })) _, err := a.Stream(context.Background(), "stream fallback") if !errors.Is(err, ai.ErrStreamingUnsupported) { t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err) } if got := mem.Messages(); len(got) != 0 { t.Fatalf("memory after unsupported stream = %+v, want no recorded messages", got) } } type unsupportedAgent struct{} func (unsupportedAgent) Name() string { return "unsupported" } func (unsupportedAgent) Init(...Option) {} func (unsupportedAgent) Options() Options { return Options{} } func (unsupportedAgent) Ask(context.Context, string) (*Response, error) { return nil, nil } func (unsupportedAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, nil } func (unsupportedAgent) Run() error { return nil } func (unsupportedAgent) Stop() error { return nil } func (unsupportedAgent) String() string { return "unsupported" }