// A2A streaming harness. // // It exercises the default, no-secret agent streaming path across the // services → agents → A2A boundary: an A2A message/stream request invokes an // agent StreamAsk turn, the agent executes a tool, and the gateway emits // working SSE task updates before the completed final answer. package main import ( "bufio" "bytes" "context" "encoding/json" "errors" "flag" "fmt" "io" "net/http" "net/http/httptest" "os" "strings" "time" "go-micro.dev/v6/agent" "go-micro.dev/v6/ai" "go-micro.dev/v6/gateway/a2a" "go-micro.dev/v6/internal/harness/harnessutil" "go-micro.dev/v6/registry" "go-micro.dev/v6/store" ) type mockModel struct{ opts ai.Options } func newMock(opts ...ai.Option) ai.Model { m := &mockModel{} _ = m.Init(opts...) return m } func (m *mockModel) Init(opts ...ai.Option) error { for _, o := range opts { o(&m.opts) } return nil } func (m *mockModel) Options() ai.Options { return m.opts } func (m *mockModel) String() string { return "mock" } func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) { return nil, ai.ErrStreamingUnsupported } func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) { if req.Prompt == "" { return nil, errors.New("missing prompt") } if len(req.Tools) == 0 || m.opts.ToolHandler == nil { return nil, errors.New("missing tools or tool handler") } res := m.opts.ToolHandler(ctx, ai.ToolCall{ID: "a2a-stream-call", Name: "stream_echo", Input: map[string]any{"value": "a2a-stream"}}) if res.Content == "" { return nil, errors.New("empty tool result") } return &ai.Response{ Reply: "streaming completed", Answer: res.Content, ToolCalls: []ai.ToolCall{{ ID: "a2a-stream-call", Name: "stream_echo", Input: map[string]any{"value": "a2a-stream"}, Result: res.Content, }}, }, nil } type agentStreamAdapter struct{ stream agent.AgentStream } func (s agentStreamAdapter) Recv() (*ai.Response, error) { for { event, err := s.stream.Recv() if err != nil { return nil, err } if event == nil { continue } switch event.Type { case agent.StreamEventToken: if event.Token != "" { return &ai.Response{Reply: event.Token}, nil } case agent.StreamEventDone: return nil, io.EOF } } } func (s agentStreamAdapter) Close() error { return s.stream.Close() } func main() { provider := flag.String("provider", "mock", "LLM provider; mock is deterministic and requires no API key") flag.Parse() if *provider == "mock" { ai.Register("mock", newMock) } fmt.Printf("\n\033[1mA2A streaming conformance (provider: %s)\033[0m\n", *provider) reg := registry.NewMemoryRegistry() st := store.NewMemoryStore() var sawTool, sawRunInfo bool ag := agent.New(append([]agent.Option{ agent.Name("a2a-streaming"), agent.Provider(*provider), agent.Prompt("Use stream_echo exactly once with value a2a-stream, then answer with the tool result."), agent.WithRegistry(reg), agent.WithStore(st), agent.WithMemory(agent.NewInMemory(8)), agent.ModelCallTimeout(45 * time.Second), agent.WithTool("stream_echo", "Echo the A2A stream marker.", map[string]any{ "value": map[string]any{"type": "string", "description": "value to echo"}, }, func(ctx context.Context, input map[string]any) (string, error) { sawTool = true info, ok := ai.RunInfoFrom(ctx) if !ok || info.RunID == "" || info.Agent != "a2a-streaming" { return "", fmt.Errorf("unexpected run info: %+v", info) } sawRunInfo = true if input["value"] != "a2a-stream" { return "", fmt.Errorf("unexpected value %v", input["value"]) } return `{"marker":"a2a-stream-ok"}`, nil }), }, harnessutil.AgentOptions(*provider)...)...) handler := a2a.NewAgentStreamHandler( a2a.Card("a2a-streaming", "http://example.invalid/a2a-streaming", "", nil), func(ctx context.Context, text string) (string, error) { resp, err := ag.Ask(ctx, text) if err != nil { return "", err } return resp.Reply, nil }, func(ctx context.Context, text string) (ai.Stream, error) { stream, err := agent.StreamAsk(ctx, ag, text) if err != nil { return nil, err } return agentStreamAdapter{stream: stream}, nil }, ) body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"Run the A2A streaming conformance check."}],"kind":"message"}}}`) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) res := rr.Result() defer res.Body.Close() if res.StatusCode != http.StatusOK { b, _ := io.ReadAll(res.Body) fmt.Fprintf(os.Stderr, "unexpected status %d: %s\n", res.StatusCode, b) os.Exit(1) } if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { fmt.Fprintf(os.Stderr, "content-type = %q, want text/event-stream\n", ct) os.Exit(1) } summary, err := readSSESummary(res.Body) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } // Spec-shaped stream: at least one artifact-update carrying the reassembled // answer, terminating in a completed status-update with final:true. if summary.ArtifactEvents == 0 || summary.State != "completed" || !summary.Final || !strings.Contains(summary.FinalText, "a2a-stream-ok") { fmt.Fprintf(os.Stderr, "unexpected stream summary: %+v\npayload:\n%s", summary, summary.Payload) os.Exit(1) } if !sawTool || !sawRunInfo { fmt.Fprintf(os.Stderr, "tool=%v runInfo=%v\n", sawTool, sawRunInfo) os.Exit(1) } fmt.Println("\n\033[32m✓ A2A message/stream emitted spec-shaped artifact/status updates and preserved tool/run metadata\033[0m") } type streamSummary struct { Payload string State string FinalText string Final bool ArtifactEvents int WorkingEvents int } func readSSESummary(r io.Reader) (streamSummary, error) { scanner := bufio.NewScanner(r) var event strings.Builder var summary streamSummary seen := false flush := func() error { data := strings.TrimSpace(event.String()) event.Reset() if data == "" { return nil } var envelope struct { Result struct { Kind string `json:"kind"` Final bool `json:"final"` Status struct { State string `json:"state"` } `json:"status"` // Task snapshots carry artifacts (plural)... Artifacts []struct { Parts []struct { Text string `json:"text"` } `json:"parts"` } `json:"artifacts"` // ...artifact-update events carry a single artifact. Artifact struct { Parts []struct { Text string `json:"text"` } `json:"parts"` } `json:"artifact"` } `json:"result"` Error any `json:"error"` } if err := json.Unmarshal([]byte(data), &envelope); err != nil { return fmt.Errorf("SSE data event is not JSON: %s", data) } if envelope.Error != nil { return fmt.Errorf("SSE data event has error: %s", data) } seen = true summary.Payload += data + "\n" switch envelope.Result.Kind { case "artifact-update": // Incremental deltas: reassemble the streamed answer. summary.ArtifactEvents++ for _, part := range envelope.Result.Artifact.Parts { summary.FinalText += part.Text } case "status-update": if envelope.Result.Status.State != "" { summary.State = envelope.Result.Status.State } if envelope.Result.Final { summary.Final = true } default: // "task" snapshot if envelope.Result.Status.State == "working" { summary.WorkingEvents++ } if envelope.Result.Status.State != "" { summary.State = envelope.Result.Status.State } // The non-streaming path carries the full text in the snapshot. for _, artifact := range envelope.Result.Artifacts { for _, part := range artifact.Parts { if part.Text != "" { summary.FinalText = part.Text } } } } return nil } for scanner.Scan() { line := scanner.Text() if strings.TrimSpace(line) == "" { if err := flush(); err != nil { return streamSummary{}, err } continue } data, ok := strings.CutPrefix(line, "data:") if !ok { continue } if event.Len() > 0 { event.WriteByte('\n') } event.WriteString(strings.TrimSpace(data)) } if err := scanner.Err(); err != nil { return streamSummary{}, err } if err := flush(); err != nil { return streamSummary{}, err } if !seen { return streamSummary{}, errors.New("no SSE data received") } return summary, nil }