diff --git a/internal/AGENTS.md b/internal/AGENTS.md index f58d285..31214e0 100644 --- a/internal/AGENTS.md +++ b/internal/AGENTS.md @@ -1,5 +1,6 @@ ### General +* Specification exists in the repository for historic purposes. Do not modify it and do not treat is as the source of truth. * Keep the CLI package clean, create a file-per-command. Try extracting logic into a different package. * Ask clarifying questions from the user if details important for the task are missing. * When working on a bug fix, follow the RED-GREEN-BLUE TDD approach. diff --git a/internal/README.md b/internal/README.md index c110c86..b3e2cca 100644 --- a/internal/README.md +++ b/internal/README.md @@ -34,7 +34,7 @@ These apply to every client-mode command. Each command selects the agent it talk | `--agent-card ` | `-a` | Agent Card reference: a host/origin (the well-known path is appended), a full card URL, or a local file path. The card is resolved and a transport negotiated. | | `--endpoint ` | `-e` | Agent interface URL for a direct connection, skipping card resolution. Must be paired with exactly one `--transport`. Mutually exclusive with `--agent-card`. | | `--transport ` | | Transport preference: `rest`, `jsonrpc`, `grpc`. Repeatable and ordered (highest preference first). With `--agent-card` it overrides the card's preference order; with `--endpoint` exactly one is required. | -| `--output ` | `-o` | Output format: `text` (default), `json`. | +| `--output ` | `-o` | Output format: `text` (default), `json` (indented), or `jsonl` (one compact JSON object per line). | | `--svc-param ` | | Service parameter (repeatable). The chosen transport defines how it's passed. Split on the first `=`. | | `--auth ` | | Shorthand for `--svc-param "Authorization="`. | | `--tenant ` | | Tenant identifier. Passed on every request. | @@ -309,8 +309,14 @@ StatusUpdate: completed ## Output Formatting -All commands support `-o json` for machine-readable output, emitting raw protocol objects. -Text mode is the default, meant for reading in a terminal. +All commands support machine-readable output, emitting raw protocol objects: + +- `-o json` — indented JSON: a single indented document, or one indented record per event under `--stream`. +- `-o jsonl` — [JSON Lines](https://jsonlines.org/): one compact JSON object per line, ideal for piping and incremental consumption under `--stream`. + +Text mode is the default, meant for reading in a terminal. The output format controls +only presentation (indentation); `--stream` independently controls whether the command +follows the agent's live events or waits for the terminal result. ## Custom Transport Plugins diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cee4466..4a00667 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -241,11 +241,8 @@ func TestSend(t *testing.T) { if err != nil { t.Fatalf("runCMD(%q) error = %v", strings.Join(tt.args(mode.url), " "), err) } - var task a2a.Task - if err := json.Unmarshal([]byte(out), &task); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if text := testutil.AllArtifactText(&task); text != tt.wantText { + task := mustDecodeTask(t, out) + if text := testutil.AllArtifactText(task); text != tt.wantText { t.Fatalf("allArtifactText() = %q, want %q", text, tt.wantText) } }) @@ -285,11 +282,8 @@ func TestSend_AgentCardFromFile(t *testing.T) { if err != nil { t.Fatalf("runCMD(%q) error = %v", strings.Join(tt.args, " "), err) } - var task a2a.Task - if err := json.Unmarshal([]byte(out), &task); err != nil { - t.Fatalf("json.Unmarshal() error = %v", err) - } - if text := testutil.AllArtifactText(&task); text != tt.wantText { + task := mustDecodeTask(t, out) + if text := testutil.AllArtifactText(task); text != tt.wantText { t.Fatalf("allArtifactText() = %q, want %q", text, tt.wantText) } }) @@ -306,11 +300,8 @@ func TestSendDataPart(t *testing.T) { } out := mustRunCMD(t, "send", "-a", url, "-o", "json", "--data-part", path) - var task a2a.Task - if err := json.Unmarshal([]byte(out), &task); err != nil { - t.Fatalf("json.Unmarshal(send --data-part output) error = %v", err) - } - if got := testutil.AllArtifactText(&task); got != `{"hello":"world"}` { + task := mustDecodeTask(t, out) + if got := testutil.AllArtifactText(task); got != `{"hello":"world"}` { t.Fatalf("allArtifactText() = %q, want %q", got, `{"hello":"world"}`) } } @@ -325,11 +316,8 @@ func TestSendRequestPayloadFile(t *testing.T) { } out := mustRunCMD(t, "send", "-a", url, "-o", "json", "--request-payload", path) - var task a2a.Task - if err := json.Unmarshal([]byte(out), &task); err != nil { - t.Fatalf("json.Unmarshal(send --request-payload output) error = %v", err) - } - if got := testutil.AllArtifactText(&task); got != "from file" { + task := mustDecodeTask(t, out) + if got := testutil.AllArtifactText(task); got != "from file" { t.Fatalf("allArtifactText() = %q, want %q", got, "from file") } } @@ -490,12 +478,79 @@ func TestSendStreaming(t *testing.T) { } } +func TestSendStreamJSONL(t *testing.T) { + t.Parallel() + url := startTestServer(t) + + testCases := []struct { + name string + flags []string + wantObjectPerLine bool + }{ + { + name: "jsonl streams one compact object per line", + flags: []string{"-a", url, "-o", "jsonl", "--stream"}, + wantObjectPerLine: true, + }, + { + name: "jsonl compact with non-streaming", + flags: []string{"-a", url, "-o", "jsonl"}, + wantObjectPerLine: true, + }, + { + name: "json streams indented records", + flags: []string{"-a", url, "-o", "json", "--stream"}, + wantObjectPerLine: false, + }, + { + name: "json indented with non-streaming", + flags: []string{"-a", url, "-o", "json"}, + wantObjectPerLine: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + command := append([]string{"send", "stream me"}, tc.flags...) + out := mustRunCMD(t, command...) + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) == 0 { + t.Fatalf("send --stream produced no JSONL lines") + } + objectPerLine := true + for i, line := range lines { + var sr a2a.StreamResponse + if err := json.Unmarshal([]byte(line), &sr); err != nil { + if tc.wantObjectPerLine { + t.Fatalf("JSONL line %d is not an independently parseable object: %v\nline: %s", i, err, line) + } + objectPerLine = false + break + } + } + if objectPerLine && !tc.wantObjectPerLine { + t.Fatalf("all outputs lines contained a well-formed a2a.StreamResponse:\n%s", out) + } + }) + } + +} + +func TestSendOutputInvalidFormat(t *testing.T) { + t.Parallel() + url := startTestServer(t) + if _, err := runCMD(t, "send", "-a", url, "-o", "yaml", "format me"); err == nil { + t.Fatal("send -o yaml error = nil, want error") + } +} + func TestSendStreamingFallbackUsesDefaultPoller(t *testing.T) { t.Parallel() nonStreamingURL := startTestServerWith(t, a2a.AgentCapabilities{Streaming: false}) out, err := runCMDWithConfig(t, deps{cfgLoader: clicfg.LoadEmpty}, - "send", "-a", nonStreamingURL, "-o", "json", "--stream", "stream me", "--polling-interval", "5ms") + "send", "-a", nonStreamingURL, "-o", "json", "--stream", "stream me", "--poll-interval", "5ms") if err != nil { t.Fatalf("runCMDWithConfig() error = %v", err) } @@ -514,6 +569,108 @@ func TestSendStreamingFallbackUsesDefaultPoller(t *testing.T) { } } +func TestSend_ResumeHintForInputRequiredTask(t *testing.T) { + t.Parallel() + + var taskID a2a.TaskID + server := httptest.NewServer(a2asrv.NewRESTHandler(a2asrv.NewHandler( + a2asrv.AgentExecutorFunc(func(ctx context.Context, ec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { + return func(yield func(a2a.Event, error) bool) { + taskID = ec.TaskID + task := &a2a.Task{ + ID: ec.TaskID, + ContextID: ec.ContextID, + Status: a2a.TaskStatus{State: a2a.TaskStateInputRequired}, + } + yield(task, nil) + } + }), + ))) + t.Cleanup(server.Close) + + out := mustRunCMD(t, "send", "-e", server.URL, "--transport", "rest", "hello") + if !strings.Contains(out, "a2a send --task-id "+string(taskID)) { + t.Fatalf("send text output missing the resume hint:\n%s", out) + } +} + +func TestSendWithVersionSelector(t *testing.T) { + t.Parallel() + url := startTestServer(t) + legacyURL := startLegacyTestServer(t) + + testCases := []struct { + name string + connect []string + version string + wantErr bool + }{ + { + name: "new server success", + connect: []string{"-a", url}, + version: "1.0", + }, + { + name: "old server success", + connect: []string{"-a", legacyURL}, + version: "0.3", + }, + { + name: "new server direct success", + connect: []string{"-e", url, "--transport", "rest"}, + version: "1.0", + }, + { + name: "old server direct success", + connect: []string{"-e", legacyURL, "--transport", "jsonrpc"}, + version: "0.3", + }, + { + name: "new server failure", + connect: []string{"-a", url}, + version: "0.3", + wantErr: true, + }, + { + name: "new server direct failure", + connect: []string{"-e", url, "--transport", "rest"}, + version: "0.3", + wantErr: true, + }, + { + name: "old server failure", + connect: []string{"-a", legacyURL}, + version: "1.0", + wantErr: true, + }, + { + name: "old server direct failure", + connect: []string{"-e", legacyURL, "--transport", "jsonrpc"}, + version: "1.0", + wantErr: true, + }, + { + name: "unknown version failure", + connect: []string{"-e", url}, + version: "3.0", + wantErr: true, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + command := []string{"send", "--a2a-version", tc.version, "-o", "json", "hi"} + command = append(command, tc.connect...) + _, err := runCMD(t, command...) + if err != nil && !tc.wantErr { + t.Fatalf("send error = %v", err) + } + if err == nil && tc.wantErr { + t.Fatal("send error = nil, wanted a failure") + } + }) + } +} + func TestGetTask(t *testing.T) { t.Parallel() url := startTestServer(t) @@ -695,6 +852,19 @@ func startLegacyTestServer(t *testing.T) string { return server.URL } +func mustDecodeTask(t *testing.T, out string) *a2a.Task { + t.Helper() + var resp a2a.StreamResponse + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("json.Unmarshal() error = %v\noutput: %s", err, out) + } + task, ok := resp.Event.(*a2a.Task) + if !ok { + t.Fatalf("send output has no task wrapper: %s", out) + } + return task +} + func sendTestMessage(t *testing.T, url, text string) a2a.TaskID { t.Helper() ctx := t.Context() diff --git a/internal/cli/client.go b/internal/cli/client.go index 3bc4736..d973aa4 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -78,6 +78,9 @@ func newClientFromEndpoint(ctx context.Context, cfg *globalConfig, ref string, e } endpoint := a2a.NewAgentInterface(endpointURL, protocol) + if cfg.a2aVersion != "" { + endpoint.ProtocolVersion = a2a.ProtocolVersion(cfg.a2aVersion) + } client, err := a2aclient.NewFromEndpoints(ctx, []*a2a.AgentInterface{endpoint}, factoryOpts...) return client, hintInsecure(err) } @@ -126,19 +129,28 @@ func hintInsecure(err error) error { } func clientFactoryOpts(cfg *globalConfig) []a2aclient.FactoryOption { - factoryOpts := []a2aclient.FactoryOption{ - a2av0.WithRESTTransport(a2av0.RESTTransportConfig{}), - a2av0.WithJSONRPCTransport(a2av0.JSONRPCTransportConfig{}), - } var grpcOpts []grpc.DialOption if cfg.insecureGRPC { grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) } - factoryOpts = append(factoryOpts, - a2agrpcv0.WithGRPCTransport(grpcOpts...), - a2agrpc.WithGRPCTransport(grpcOpts...), - ) - return factoryOpts + opts := []a2aclient.FactoryOption{a2aclient.WithDefaultsDisabled()} + if cfg.a2aVersion == "" || cfg.a2aVersion == "1.0" { + opts = append( + opts, + a2aclient.WithRESTTransport(nil), + a2aclient.WithJSONRPCTransport(nil), + a2agrpc.WithGRPCTransport(grpcOpts...), + ) + } + if cfg.a2aVersion == "" || cfg.a2aVersion == "0.3" { + opts = append( + opts, + a2av0.WithRESTTransport(a2av0.RESTTransportConfig{}), + a2av0.WithJSONRPCTransport(a2av0.JSONRPCTransportConfig{}), + a2agrpcv0.WithGRPCTransport(grpcOpts...), + ) + } + return opts } func stripHTTPScheme(raw string) string { diff --git a/internal/cli/config_show.go b/internal/cli/config_show.go index 35a1035..bede547 100644 --- a/internal/cli/config_show.go +++ b/internal/cli/config_show.go @@ -20,8 +20,6 @@ import ( "text/tabwriter" "github.com/spf13/cobra" - - "github.com/a2aproject/a2a-cli/internal/output" ) type flagBindingView struct { @@ -58,7 +56,7 @@ func newConfigShowCmd(cfg *globalConfig) *cobra.Command { views = append(views, view) } - if cfg.Mode == output.ModeJson { + if cfg.IsJSON() { return cfg.PrintJSON(views) } diff --git a/internal/cli/root.go b/internal/cli/root.go index f990d7c..078005c 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -50,6 +50,7 @@ type globalConfig struct { url string transports []string svcParams *flagparse.ServiceParams + a2aVersion string tenant string timeout time.Duration verbose bool @@ -102,20 +103,21 @@ func newRootCmd(cfg *globalConfig, deps deps) *cobra.Command { cfg.bindings = bindings switch output.Mode(cfg.output) { - case output.ModeText, output.ModeJson: + case output.ModeText, output.ModeJson, output.ModeJSONL: cfg.Mode = output.Mode(cfg.output) default: - return fmt.Errorf("invalid --output %q (want text or json)", cfg.output) + return fmt.Errorf("invalid --output %q (want text, json, or jsonl)", cfg.output) } return nil }, } pf := cmd.PersistentFlags() - pf.StringVarP(&cfg.output, "output", "o", "text", "Output format: text, json") + pf.StringVarP(&cfg.output, "output", "o", "text", "Output format: text, json (indented), or jsonl (one compact JSON object per line)") pf.VarP(&cfg.agentCard, "agent-card", "a", "Agent Card reference: host/origin, full card URL, or local file path") pf.StringVarP(&cfg.url, "endpoint", "e", "", "Agent interface URL for a direct connection; skips card resolution and requires a single --transport flag") pf.StringArrayVar(&cfg.transports, "transport", nil, "Transport preference: rest, jsonrpc, grpc, or an installed plugin name (repeatable, highest preference first)") + pf.StringVar(&cfg.a2aVersion, "a2a-version", "", "Controls which a2a-protocol version client will advertise to the server.") cfg.svcParams.Attach(pf) pf.StringVar(&cfg.tenant, "tenant", "", "Tenant identifier") pf.DurationVar(&cfg.timeout, "timeout", 30*time.Second, "Request timeout") diff --git a/internal/cli/send.go b/internal/cli/send.go index 2c0b557..0132a67 100644 --- a/internal/cli/send.go +++ b/internal/cli/send.go @@ -31,15 +31,15 @@ import ( ) type sendFlags struct { - stream bool - async bool - payload string - taskID string - contextID string - history int - pollingInterval time.Duration - parts flagparse.Parts - meta flagparse.Metadata + stream bool + async bool + payload string + taskID string + contextID string + history int + pollInterval time.Duration + parts flagparse.Parts + meta flagparse.Metadata } type pollerFunc func(ctx context.Context, client *a2aclient.Client, req *a2a.SendMessageRequest, interval time.Duration) iter.Seq2[a2a.Event, error] @@ -81,9 +81,9 @@ func newSendCmd(cfg *globalConfig, poller pollerFunc) *cobra.Command { return utils.UnpackCause(ctx, err) } - cfg.logf("falling back to polling (%v): %v", flags.pollingInterval, err) + cfg.logf("falling back to polling (%v): %v", flags.pollInterval, err) - for event, err := range poller(ctx, client, req, flags.pollingInterval) { + for event, err := range poller(ctx, client, req, flags.pollInterval) { debounceTimeout() if err := handleStreamEntry(cfg, event, err); err != nil { return utils.UnpackCause(ctx, err) @@ -113,7 +113,7 @@ func newSendCmd(cfg *globalConfig, poller pollerFunc) *cobra.Command { f.StringVar(&flags.taskID, "task-id", "", "Task ID to continue an existing task") f.StringVar(&flags.contextID, "context-id", "", "Context ID to group this turn under") f.IntVar(&flags.history, "history", 0, "Request n history messages in the response") - f.DurationVar(&flags.pollingInterval, "polling-interval", 5*time.Second, "Duration between GetTask requests in polling fallback mode.") + f.DurationVar(&flags.pollInterval, "poll-interval", 2*time.Second, "Duration between GetTask requests in polling fallback mode.") flags.parts.Attach(f) flags.meta.Attach(f, "metadata", "Attach request metadata as a JSON object (repeatable)") diff --git a/internal/cli/transport_list.go b/internal/cli/transport_list.go index de98f9f..012b4b1 100644 --- a/internal/cli/transport_list.go +++ b/internal/cli/transport_list.go @@ -21,7 +21,6 @@ import ( "github.com/spf13/cobra" - "github.com/a2aproject/a2a-cli/internal/output" "github.com/a2aproject/a2a-cli/internal/transportplugin" ) @@ -42,7 +41,7 @@ func newTransportListCmd(cfg *globalConfig) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { entries := collectTransportEntries(cmd) - if cfg.Mode == output.ModeJson { + if cfg.IsJSON() { return cfg.PrintJSON(entries) } return printTransportTable(cfg.Out, entries) diff --git a/internal/cli/transport_test.go b/internal/cli/transport_test.go index 1c620f9..42abb2c 100644 --- a/internal/cli/transport_test.go +++ b/internal/cli/transport_test.go @@ -59,11 +59,8 @@ func TestTransportPluginIntegration(t *testing.T) { t.Run("send proxies through the plugin", func(t *testing.T) { out := mustRunCMD(t, "send", "--transport", "echo", "--endpoint", "echo://demo", "-o", "json", "hello plugin") - var task a2a.Task - if err := json.Unmarshal([]byte(out), &task); err != nil { - t.Fatalf("json.Unmarshal(send output) error = %v", err) - } - if got := testutil.AllArtifactText(&task); got != "hello plugin" { + task := mustDecodeTask(t, out) + if got := testutil.AllArtifactText(task); got != "hello plugin" { t.Fatalf("send via echo plugin artifact text = %q, want %q", got, "hello plugin") } }) diff --git a/internal/flagparse/svcparams.go b/internal/flagparse/svcparams.go index 1426445..add874c 100644 --- a/internal/flagparse/svcparams.go +++ b/internal/flagparse/svcparams.go @@ -16,7 +16,6 @@ package flagparse import ( "fmt" - "strings" "github.com/spf13/pflag" @@ -70,9 +69,9 @@ func (s *ServiceParams) Auth() string { type svcParamValue struct{ s *ServiceParams } func (v *svcParamValue) Set(kv string) error { - k, val, ok := strings.Cut(kv, "=") + k, val, ok := cutServiceParam(kv) if !ok { - return fmt.Errorf("expected key=value, got %q", kv) + return fmt.Errorf("expected key=value or key:value, got %q", kv) } if k == "" { return fmt.Errorf("empty key in %q", kv) @@ -81,6 +80,21 @@ func (v *svcParamValue) Set(kv string) error { return nil } +// cutServiceParam splits a --svc-param argument on whichever of ':' or '=' comes first. +func cutServiceParam(kv string) (key, value string, ok bool) { + sep := -1 + for i := 0; i < len(kv); i++ { + if kv[i] == ':' || kv[i] == '=' { + sep = i + break + } + } + if sep < 0 { + return "", "", false + } + return kv[:sep], kv[sep+1:], true +} + func (v *svcParamValue) String() string { return "" } func (v *svcParamValue) Type() string { return "key=value" } diff --git a/internal/flagparse/svcparams_test.go b/internal/flagparse/svcparams_test.go index b5a88ba..b5871b5 100644 --- a/internal/flagparse/svcparams_test.go +++ b/internal/flagparse/svcparams_test.go @@ -43,6 +43,31 @@ func TestServiceParamsParse(t *testing.T) { args: []string{"--svc-param", "k=a=b"}, want: a2aclient.ServiceParams{"k": {"a=b"}}, }, + { + name: "colon separator", + args: []string{"--svc-param", "x-trace:abc"}, + want: a2aclient.ServiceParams{"x-trace": {"abc"}}, + }, + { + name: "colon value may contain colon", + args: []string{"--svc-param", "redirect:http://example.com"}, + want: a2aclient.ServiceParams{"redirect": {"http://example.com"}}, + }, + { + name: "equals before colon splits on equals", + args: []string{"--svc-param", "url=http://example.com"}, + want: a2aclient.ServiceParams{"url": {"http://example.com"}}, + }, + { + name: "colon before equals splits on colon", + args: []string{"--svc-param", "x-trace:a=b"}, + want: a2aclient.ServiceParams{"x-trace": {"a=b"}}, + }, + { + name: "empty key with colon is an error", + args: []string{"--svc-param", ":value"}, + wantErr: true, + }, { name: "repeated keys append in order", args: []string{"--svc-param", "k=1", "--svc-param", "k=2"}, diff --git a/internal/output/output.go b/internal/output/output.go index 14bf4b7..e6936a7 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -29,9 +29,13 @@ import ( // Mode selects how a Printer renders values. type Mode string -// ModeJson renders values as indented JSON. +// ModeJson renders values as indented JSON: a single indented document, or one +// indented record per streamed event. const ModeJson Mode = "json" +// ModeJSONL renders values as JSON Lines: one compact JSON object per line. +const ModeJSONL Mode = "jsonl" + // ModeText renders values as human-readable text. const ModeText Mode = "text" @@ -57,16 +61,32 @@ func NewPrinter(out io.Writer, mode Mode) *Printer { return &Printer{Out: out, Mode: mode} } -// PrintJSON writes v as indented JSON. -func (p *Printer) PrintJSON(v any) error { +// IsJSON reports whether the printer renders machine-readable JSON, either +// indented (ModeJson) or one compact object per line (ModeJSONL). +func (p *Printer) IsJSON() bool { + return p.Mode == ModeJson || p.Mode == ModeJSONL +} + +// jsonEncoder returns a JSON encoder configured for the current mode: indented +// for ModeJson, compact for ModeJSONL. +func (p *Printer) jsonEncoder() *json.Encoder { enc := json.NewEncoder(p.Out) - enc.SetIndent("", " ") - return enc.Encode(v) + enc.SetEscapeHTML(false) + if p.Mode == ModeJson { + enc.SetIndent("", " ") + } + return enc +} + +// PrintJSON writes v as a single JSON document, indented in ModeJson and +// compact in ModeJSONL. +func (p *Printer) PrintJSON(v any) error { + return p.jsonEncoder().Encode(v) } // PrintCard writes an agent card in the configured Mode. func (p *Printer) PrintCard(card *a2a.AgentCard) error { - if p.Mode == ModeJson { + if p.IsJSON() { return p.PrintJSON(card) } _, err := io.WriteString(p.Out, formatCard(card)) @@ -75,18 +95,21 @@ func (p *Printer) PrintCard(card *a2a.AgentCard) error { // PrintTask writes a task in the configured Mode. func (p *Printer) PrintTask(task *a2a.Task) error { - if p.Mode == ModeJson { + if p.IsJSON() { return p.PrintJSON(task) } - _, err := io.WriteString(p.Out, formatTask(task)) + _, err := io.WriteString(p.Out, formatTask(task)+formatResumeHint(task)) return err } -// PrintEvent writes a streaming event in the configured Mode. +// PrintEvent writes a streaming event in the configured Mode. In ModeJson each +// event is an indented record; in ModeJSONL each event is one compact object per +// line. func (p *Printer) PrintEvent(event a2a.Event) error { - if p.Mode == ModeJson { - return p.PrintJSON(a2a.StreamResponse{Event: event}) + if p.IsJSON() { + return p.jsonEncoder().Encode(a2a.StreamResponse{Event: event}) } + var s string switch e := event.(type) { case *a2a.TaskStatusUpdateEvent: @@ -116,23 +139,24 @@ func (p *Printer) PrintEvent(event a2a.Event) error { // PrintSendResult writes the result of a send-message call in the configured Mode. func (p *Printer) PrintSendResult(result a2a.SendMessageResult) error { - if p.Mode == ModeJson { - return p.PrintJSON(result) + if p.IsJSON() { + return p.PrintJSON(a2a.StreamResponse{Event: result}) } + switch r := result.(type) { case *a2a.Task: - _, err := io.WriteString(p.Out, formatTask(r)) + _, err := io.WriteString(p.Out, formatTask(r)+formatResumeHint(r)) return err case *a2a.Message: _, err := io.WriteString(p.Out, formatMessage(r)) return err } - return nil + return fmt.Errorf("unexpected send result type %T", result) } // PrintTaskList writes a list of tasks in the configured Mode. func (p *Printer) PrintTaskList(resp *a2a.ListTasksResponse) error { - if p.Mode == ModeJson { + if p.IsJSON() { return p.PrintJSON(resp) } _, err := io.WriteString(p.Out, formatTaskList(resp)) @@ -142,7 +166,7 @@ func (p *Printer) PrintTaskList(resp *a2a.ListTasksResponse) error { // PrintPushConfig writes a single push-notification configuration in the // configured Mode. In text mode the auth credentials are redacted. func (p *Printer) PrintPushConfig(pc *a2a.PushConfig) error { - if p.Mode == ModeJson { + if p.IsJSON() { return p.PrintJSON(pc) } _, err := io.WriteString(p.Out, formatPushConfig(pc)) @@ -152,7 +176,7 @@ func (p *Printer) PrintPushConfig(pc *a2a.PushConfig) error { // PrintPushConfigList writes a list of push-notification configurations in the // configured Mode. func (p *Printer) PrintPushConfigList(configs []*a2a.PushConfig) error { - if p.Mode == ModeJson { + if p.IsJSON() { return p.PrintJSON(configs) } tw := tabwriter.NewWriter(p.Out, 0, 4, 2, ' ', 0) @@ -169,7 +193,7 @@ func (p *Printer) PrintPushConfigList(configs []*a2a.PushConfig) error { // PrintPushConfigDeleted confirms deletion of a push-notification configuration. func (p *Printer) PrintPushConfigDeleted(taskID, configID string) error { - if p.Mode == ModeJson { + if p.IsJSON() { return p.PrintJSON(map[string]any{"deleted": true, "taskId": taskID, "id": configID}) } _, err := fmt.Fprintf(p.Out, "Deleted: %s (task %s)\n", configID, taskID) @@ -263,6 +287,17 @@ func formatTask(task *a2a.Task) string { return sb.String() } +// formatResumeHint returns a copy-pasteable command to continue or reply to a task. +func formatResumeHint(task *a2a.Task) string { + if task.ID == "" { + return "" + } + if task.Status.State != a2a.TaskStateInputRequired && task.Status.State != a2a.TaskStateAuthRequired { + return "" + } + return fmt.Sprintf("\n\nResume: a2a send --task-id %s %q\n", task.ID, "") +} + func formatMessage(msg *a2a.Message) string { role := "user" if msg.Role == a2a.MessageRoleAgent { diff --git a/specification/SPEC.md b/specification/SPEC.md index f93bc8f..bf101b3 100644 --- a/specification/SPEC.md +++ b/specification/SPEC.md @@ -492,7 +492,7 @@ A client MAY express its own preference with `--transport`, which is **repeatabl Cross-cutting options such as `--insecure` apply to whichever transport is negotiated. Where a future option is meaningful only for one binding (for example a gRPC keepalive setting that HTTP has no analogue for), a tool SHOULD namespace it per transport rather than overloading a global flag; the reserved convention is `---