@pi-in-go/pigpen-a2a 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CREDITS.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +97 -0
  4. package/extensions/a2a/auth.go +120 -0
  5. package/extensions/a2a/auth_test.go +133 -0
  6. package/extensions/a2a/bench_test.go +70 -0
  7. package/extensions/a2a/binary_test.go +225 -0
  8. package/extensions/a2a/cancelqueued_test.go +142 -0
  9. package/extensions/a2a/card_cache_test.go +42 -0
  10. package/extensions/a2a/client.go +499 -0
  11. package/extensions/a2a/client_test.go +402 -0
  12. package/extensions/a2a/config.go +282 -0
  13. package/extensions/a2a/config_test.go +194 -0
  14. package/extensions/a2a/e2e_test.go +311 -0
  15. package/extensions/a2a/executor.go +318 -0
  16. package/extensions/a2a/extension.go +382 -0
  17. package/extensions/a2a/extension_test.go +396 -0
  18. package/extensions/a2a/fakehost_test.go +548 -0
  19. package/extensions/a2a/fakepig_test.go +212 -0
  20. package/extensions/a2a/gaps_test.go +59 -0
  21. package/extensions/a2a/go.mod +15 -0
  22. package/extensions/a2a/go.sum +14 -0
  23. package/extensions/a2a/interop_test.go +163 -0
  24. package/extensions/a2a/procattr_other.go +25 -0
  25. package/extensions/a2a/procattr_windows.go +26 -0
  26. package/extensions/a2a/resubscribe_test.go +113 -0
  27. package/extensions/a2a/review_test.go +293 -0
  28. package/extensions/a2a/server.go +267 -0
  29. package/extensions/a2a/server_test.go +792 -0
  30. package/extensions/a2a/survivors_test.go +302 -0
  31. package/extensions/a2a/worker.go +388 -0
  32. package/extensions/a2a/worker_test.go +257 -0
  33. package/package.json +41 -0
  34. package/port/PORT.md +126 -0
  35. package/port/a2a-go-LICENSE +201 -0
  36. package/port/golden/flag-without-auth-refused.jsonl +5 -0
  37. package/port/golden/listener-off.jsonl +4 -0
  38. package/port/golden/send-missing-message.jsonl +18 -0
  39. package/port/golden/send-without-remotes.jsonl +18 -0
  40. package/port/golden/task-unknown-action.jsonl +18 -0
  41. package/port/golden/tools-visible-to-model.jsonl +11 -0
  42. package/port/interop/kagent/main.go +73 -0
  43. package/port/mutation-run.txt +222 -0
  44. package/port/mutations.json +656 -0
  45. package/port/red.txt +105 -0
  46. package/port/scenarios/flag-without-auth-refused.json +8 -0
  47. package/port/scenarios/listener-off.json +7 -0
  48. package/port/scenarios/send-missing-message.json +11 -0
  49. package/port/scenarios/send-without-remotes.json +11 -0
  50. package/port/scenarios/task-unknown-action.json +11 -0
  51. package/port/scenarios/tools-visible-to-model.json +8 -0
  52. package/provenance.json +18 -0
@@ -0,0 +1,212 @@
1
+ package a2aext
2
+
3
+ // The fake `pig --mode rpc`: the test binary re-executed (Skill step 3, "fake external CLI").
4
+ // It speaks the JSONL protocol of `pig --mode rpc` for the commands the worker uses and logs
5
+ // what it saw to $A2A_FAKE_LOG so tests can assert on the argv, environment and abort.
6
+
7
+ import (
8
+ "bufio"
9
+ "encoding/json"
10
+ "fmt"
11
+ "os"
12
+ "os/exec"
13
+ "os/signal"
14
+ "strings"
15
+ "syscall"
16
+ "testing"
17
+ "time"
18
+ )
19
+
20
+ func TestMain(m *testing.M) {
21
+ if os.Getenv("A2A_FAKE_PIG") == "1" {
22
+ fakePig()
23
+ return
24
+ }
25
+ os.Exit(m.Run())
26
+ }
27
+
28
+ type fakeLog struct {
29
+ Terminated bool `json:"terminated"`
30
+ Args []string `json:"args"`
31
+ Cwd string `json:"cwd"`
32
+ Env map[string]string `json:"env"`
33
+ PID int `json:"pid"`
34
+ Prompt string `json:"prompt"`
35
+ Started bool `json:"started"`
36
+ Aborted bool `json:"aborted"`
37
+ Finished bool `json:"finished"`
38
+ ChildPID int `json:"childPid"`
39
+ }
40
+
41
+ func fakePig() {
42
+ logPath := os.Getenv("A2A_FAKE_LOG")
43
+ cwd, _ := os.Getwd()
44
+ entry := fakeLog{Args: os.Args[1:], Cwd: cwd, PID: os.Getpid(), Env: map[string]string{}}
45
+ for _, kv := range os.Environ() {
46
+ k, v, _ := strings.Cut(kv, "=")
47
+ entry.Env[k] = v
48
+ }
49
+ save := func() {
50
+ if logPath == "" {
51
+ return
52
+ }
53
+ b, _ := json.Marshal(entry)
54
+ _ = os.WriteFile(logPath, b, 0o600)
55
+ }
56
+ save()
57
+ // A graceful stop (SIGTERM) is recorded; "IGNORE_TERM" prompts ignore it too, so only SIGKILL ends them.
58
+ term := make(chan os.Signal, 1)
59
+ signal.Notify(term, syscall.SIGTERM)
60
+ go func() {
61
+ <-term
62
+ entry.Terminated = true
63
+ save()
64
+ if !strings.Contains(entry.Prompt, "IGNORE_TERM") {
65
+ os.Exit(0)
66
+ }
67
+ }()
68
+ out := bufio.NewWriter(os.Stdout)
69
+ emit := func(v map[string]any) {
70
+ b, _ := json.Marshal(v)
71
+ out.Write(append(b, '\n'))
72
+ out.Flush()
73
+ }
74
+ settle := func(stop string, text, errMsg string) {
75
+ msg := map[string]any{"role": "assistant", "stopReason": stop, "content": []any{map[string]any{"type": "text", "text": text}}}
76
+ if errMsg != "" {
77
+ msg["errorMessage"] = errMsg
78
+ }
79
+ emit(map[string]any{"type": "message_end", "message": msg})
80
+ emit(map[string]any{"type": "agent_end", "willRetry": false})
81
+ emit(map[string]any{"type": "agent_settled"})
82
+ entry.Finished = true
83
+ save()
84
+ }
85
+ in := bufio.NewScanner(os.Stdin)
86
+ in.Buffer(make([]byte, 1<<20), 1<<24)
87
+ for in.Scan() {
88
+ var cmd struct {
89
+ ID string `json:"id"`
90
+ Type string `json:"type"`
91
+ Message string `json:"message"`
92
+ }
93
+ if json.Unmarshal(in.Bytes(), &cmd) != nil {
94
+ continue
95
+ }
96
+ switch cmd.Type {
97
+ case "prompt":
98
+ entry.Prompt, entry.Started = cmd.Message, true
99
+ save()
100
+ if strings.Contains(cmd.Message, "REJECT") {
101
+ emit(map[string]any{"id": cmd.ID, "type": "response", "command": "prompt", "success": false, "error": "secret internal reason"})
102
+ continue
103
+ }
104
+ if strings.Contains(cmd.Message, "ORPHAN") {
105
+ // A tool that outlives the turn: a child in the worker's process group that nobody waits for.
106
+ child := exec.Command("sleep", "60")
107
+ if child.Start() == nil {
108
+ entry.ChildPID = child.Process.Pid
109
+ save()
110
+ }
111
+ }
112
+ emit(map[string]any{"id": cmd.ID, "type": "response", "command": "prompt", "success": true})
113
+ emit(map[string]any{"type": "agent_start"})
114
+ emit(map[string]any{"type": "message_start", "message": map[string]any{"role": "assistant"}})
115
+ switch {
116
+ case strings.Contains(cmd.Message, "NEEDUI"):
117
+ emit(map[string]any{"type": "extension_ui_request", "id": "ui-1", "method": "confirm", "title": "sure?"})
118
+ select {} // a real PiG would wait for the answer
119
+ case strings.Contains(cmd.Message, "IGNORE_ABORT"), strings.Contains(cmd.Message, "IGNORE_TERM"):
120
+ select {} // hang until killed
121
+ case strings.Contains(cmd.Message, "SLOW"):
122
+ emit(map[string]any{"type": "message_update", "assistantMessageEvent": map[string]any{"type": "text_delta", "contentIndex": 0, "delta": "working"}})
123
+ // wait for abort in the read loop
124
+ case strings.Contains(cmd.Message, "FAIL"):
125
+ settle("error", "", "provider exploded with secret sk-123")
126
+ case strings.Contains(cmd.Message, "TOOL"):
127
+ emit(map[string]any{"type": "tool_execution_start", "toolCallId": "t1", "toolName": "read", "args": map[string]any{"path": "x"}})
128
+ emit(map[string]any{"type": "tool_execution_end", "toolCallId": "t1", "toolName": "read", "result": map[string]any{"content": []any{}}, "isError": false})
129
+ emit(map[string]any{"type": "message_update", "assistantMessageEvent": map[string]any{"type": "text_delta", "contentIndex": 0, "delta": "done"}})
130
+ settle("stop", "done", "")
131
+ default:
132
+ h := len(cmd.Message) / 2
133
+ emit(map[string]any{"type": "message_update", "assistantMessageEvent": map[string]any{"type": "text_delta", "contentIndex": 0, "delta": "echo: " + cmd.Message[:h]}})
134
+ emit(map[string]any{"type": "message_update", "assistantMessageEvent": map[string]any{"type": "text_delta", "contentIndex": 0, "delta": cmd.Message[h:]}})
135
+ settle("stop", "echo: "+cmd.Message, "")
136
+ }
137
+ case "abort":
138
+ entry.Aborted = true
139
+ save()
140
+ emit(map[string]any{"id": cmd.ID, "type": "response", "command": "abort", "success": true})
141
+ settle("aborted", "working", "")
142
+ }
143
+ }
144
+ }
145
+
146
+ // fakeWorkerConfig points a WorkerConfig at the fake pig and returns the log path.
147
+ func fakeWorkerConfig(t *testing.T) (WorkerConfig, string, func(string) string) {
148
+ t.Helper()
149
+ exe, err := os.Executable()
150
+ if err != nil {
151
+ t.Fatal(err)
152
+ }
153
+ logPath := t.TempDir() + "/fake.log"
154
+ env := map[string]string{
155
+ "A2A_FAKE_PIG": "1", "A2A_FAKE_LOG": logPath, "PATH": os.Getenv("PATH"), "HOME": "/home/x",
156
+ "PIG_HOME": "/pig/home", "PIG_CODING_AGENT_DIR": "/pig/agent",
157
+ "TOKEN_A": tokenA, "OPENAI_API_KEY": "sk-passthrough", "SOMETHING_SECRET": "leak",
158
+ }
159
+ cfg := WorkerConfig{Command: exe, Tools: []string{"read", "grep", "find", "ls"}, Cwd: t.TempDir(), GraceSeconds: 1,
160
+ PassEnv: []string{"A2A_FAKE_PIG", "A2A_FAKE_LOG", "OPENAI_API_KEY"}}
161
+ return cfg, logPath, func(k string) string { return env[k] }
162
+ }
163
+
164
+ func readFakeLog(t *testing.T, path string) fakeLog {
165
+ t.Helper()
166
+ deadline := time.Now().Add(5 * time.Second)
167
+ for {
168
+ b, err := os.ReadFile(path)
169
+ var l fakeLog
170
+ if err == nil && json.Unmarshal(b, &l) == nil && l.PID != 0 {
171
+ return l
172
+ }
173
+ if time.Now().After(deadline) {
174
+ t.Fatalf("no fake pig log at %s: %v", path, err)
175
+ }
176
+ time.Sleep(10 * time.Millisecond)
177
+ }
178
+ }
179
+
180
+ func waitFakeLog(t *testing.T, path string, ok func(fakeLog) bool) fakeLog {
181
+ t.Helper()
182
+ deadline := time.Now().Add(10 * time.Second)
183
+ for {
184
+ l := readFakeLog(t, path)
185
+ if ok(l) {
186
+ return l
187
+ }
188
+ if time.Now().After(deadline) {
189
+ t.Fatalf("condition not reached: %+v", l)
190
+ }
191
+ time.Sleep(10 * time.Millisecond)
192
+ }
193
+ }
194
+
195
+ func processAlive(pid int) bool {
196
+ p, err := os.FindProcess(pid)
197
+ if err != nil {
198
+ return false
199
+ }
200
+ return p.Signal(syscall.Signal(0)) == nil
201
+ }
202
+
203
+ func argValue(args []string, flag string) string {
204
+ for i, a := range args {
205
+ if a == flag && i+1 < len(args) {
206
+ return args[i+1]
207
+ }
208
+ }
209
+ return ""
210
+ }
211
+
212
+ var _ = fmt.Sprintf
@@ -0,0 +1,59 @@
1
+ package a2aext
2
+
3
+ import "testing"
4
+
5
+ // Every gap between the A2A 1.0 specification / a2a-go and this adapter is a named, skipped
6
+ // test (Skill step 3: "document every gap as a named skipped test, never silently").
7
+ // port/PORT.md lists them with the count.
8
+
9
+ func TestGap_GRPCTransportBinding(t *testing.T) {
10
+ t.Skip("gap: only the JSON-RPC binding is served. a2a-go has gRPC and REST bindings; each is another authenticated surface to secure and test. Owner decision to add.")
11
+ }
12
+
13
+ func TestGap_RESTTransportBinding(t *testing.T) {
14
+ t.Skip("gap: the HTTP+JSON (REST) binding is not served, only JSON-RPC. See GRPC gap.")
15
+ }
16
+
17
+ func TestGap_PushNotifications(t *testing.T) {
18
+ t.Skip("gap: push notification configs are not supported (the card says pushNotifications=false). Webhook delivery needs SSRF policy for peer-supplied URLs.")
19
+ }
20
+
21
+ func TestGap_ProtocolV03Compatibility(t *testing.T) {
22
+ t.Skip("gap: A2A 0.3 peers are refused with VERSION_NOT_SUPPORTED (server) and no 0.3 client transport is enabled. a2a-go ships a2acompat/a2av0; enabling it needs an owner decision because 0.3 has different task-state and auth semantics.")
23
+ }
24
+
25
+ func TestGap_ExtendedAgentCard(t *testing.T) {
26
+ t.Skip("gap: GetExtendedAgentCard is not supported; the card is the same for every caller.")
27
+ }
28
+
29
+ func TestGap_PersistentTaskStore(t *testing.T) {
30
+ t.Skip("gap: the task store is in memory. Tasks and their status are lost when the listener restarts, although each context's PiG session file persists on disk and a new task in the same contextId continues it.")
31
+ }
32
+
33
+ func TestGap_InputRequiredFromPiG(t *testing.T) {
34
+ t.Skip("gap: a PiG turn never ends in input-required; the worker runs headless and extension UI requests are not surfaced to the peer. Peers see completed, failed or canceled.")
35
+ }
36
+
37
+ func TestGap_FileAndDataParts(t *testing.T) {
38
+ t.Skip("gap: only text parts are accepted and produced. File, raw and data parts are refused, not dropped.")
39
+ }
40
+
41
+ func TestGap_OAuth2AndMutualTLSAuthentication(t *testing.T) {
42
+ t.Skip("gap: server authentication is static bearer tokens named by environment variable, mapped to a principal and tenant. OAuth2/OIDC validation and mTLS client certificates are not implemented; the client sends a bearer token or custom headers only.")
43
+ }
44
+
45
+ func TestGap_SignedAgentCards(t *testing.T) {
46
+ t.Skip("gap: agent card signatures are neither produced nor verified.")
47
+ }
48
+
49
+ func TestGap_SubscribeToTaskAfterRestart(t *testing.T) {
50
+ t.Skip("gap: SubscribeToTask works only while the task is live in this process (see the persistent task store gap).")
51
+ }
52
+
53
+ func TestGap_ClientStreamingProgressToTheModel(t *testing.T) {
54
+ t.Skip("gap: a2a_send waits for a terminal or input-required state and reports progress through the tool update channel only; there is no long-poll or resubscribe tool for tasks that outlive the call.")
55
+ }
56
+
57
+ func TestGap_ClientResubscribeTool(t *testing.T) {
58
+ t.Skip("gap: the client tools do not resubscribe to a remote task's stream; a2a_task get/cancel and a2a_send (which streams) cover the model's needs. The server side does support SubscribeToTask for a live task (TestSubscribeToALiveTaskDeliversTheRest).")
59
+ }
@@ -0,0 +1,15 @@
1
+ module github.com/MichaelKinsy/pigpen/a2a
2
+
3
+ go 1.26.0
4
+
5
+ // PiG resolves this requirement to the version-matched staged SDK at build time.
6
+ require github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1
7
+
8
+ require github.com/a2aproject/a2a-go/v2 v2.6.0
9
+
10
+ require (
11
+ github.com/google/uuid v1.6.0 // indirect
12
+ golang.org/x/mod v0.38.0 // indirect
13
+ golang.org/x/sync v0.22.0 // indirect
14
+ )
15
+
@@ -0,0 +1,14 @@
1
+ github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1 h1:Ql9sTkc6dvAXaYQLq5nxqmSZgqu8S0L53ZTs8MkOuxw=
2
+ github.com/MichaelKinsy/PiG/extensions/sdk v0.4.1/go.mod h1:4j0XhCW0EPOBWvqYE7XKjTvIAahI7K1VyyW3KpjTKj8=
3
+ github.com/a2aproject/a2a-go/v2 v2.6.0 h1:QflB0gHxOP0o5quvA/h9EbYgfOsmRsSmFkeBYtKdwwk=
4
+ github.com/a2aproject/a2a-go/v2 v2.6.0/go.mod h1:rvFIb0sBJl2qmJRYU2fQxI6hhIQksmGGMEhT/uRwWww=
5
+ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
6
+ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
7
+ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
8
+ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
9
+ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
10
+ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
11
+ golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
12
+ golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
13
+ golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
14
+ golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
@@ -0,0 +1,163 @@
1
+ package a2aext
2
+
3
+ // Interoperability with kagent. kagent speaks A2A 1.0 through a2a-go v2 (kagent's go.mod
4
+ // requires github.com/a2aproject/a2a-go/v2 v2.6.0, the version pinned here). The test drives a
5
+ // real kagent A2A server: kagent's own adk/pkg/a2a/server package with an echo executor
6
+ // (port/interop/kagent, built by scripts/interop-kagent.mjs from a kagent checkout).
7
+ // Without KAGENT_A2A_ECHO the tests are skipped, by name, and the report says so.
8
+
9
+ import (
10
+ "bufio"
11
+ "context"
12
+ "fmt"
13
+ "net"
14
+ "net/http"
15
+ "os"
16
+ "os/exec"
17
+ "strings"
18
+ "testing"
19
+ "time"
20
+
21
+ "github.com/a2aproject/a2a-go/v2/a2a"
22
+ "github.com/a2aproject/a2a-go/v2/a2aclient"
23
+ )
24
+
25
+ func startKagentEcho(t *testing.T) string {
26
+ t.Helper()
27
+ bin := os.Getenv("KAGENT_A2A_ECHO")
28
+ if bin == "" {
29
+ t.Skip("no kagent-compatible A2A endpoint: set KAGENT_A2A_ECHO to the binary built by scripts/interop-kagent.mjs (needs a kagent checkout, KAGENT_GO_DIR)")
30
+ }
31
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
32
+ if err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ port := fmt.Sprint(ln.Addr().(*net.TCPAddr).Port)
36
+ ln.Close()
37
+ cmd := exec.Command(bin, "-port", port)
38
+ cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + t.TempDir()}
39
+ out, _ := cmd.StderrPipe()
40
+ if err := cmd.Start(); err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ go func() { // keep the pipe drained
44
+ sc := bufio.NewScanner(out)
45
+ for sc.Scan() {
46
+ }
47
+ }()
48
+ t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
49
+ base := "http://127.0.0.1:" + port
50
+ deadline := time.Now().Add(20 * time.Second)
51
+ for {
52
+ resp, err := http.Get(base + "/.well-known/agent-card.json")
53
+ if err == nil {
54
+ resp.Body.Close()
55
+ return base
56
+ }
57
+ if time.Now().After(deadline) {
58
+ t.Fatalf("kagent echo did not start: %v", err)
59
+ }
60
+ time.Sleep(50 * time.Millisecond)
61
+ }
62
+ }
63
+
64
+ func TestInterop_KagentServerAsRemote(t *testing.T) {
65
+ base := startKagentEcho(t)
66
+ // kagent's card advertises the in-cluster URL, so callers use the configured URL as the endpoint (skipCard),
67
+ // exactly as kagent's own CLI does.
68
+ rs := NewRemotes(map[string]RemoteAgent{"kagent": {URL: base, SkipCard: true, TimeoutSeconds: 30}}, envFrom(nil))
69
+ var progress strings.Builder
70
+ sum, err := rs.Send(context.Background(), SendArgs{Agent: "kagent", Message: "hello kagent"}, func(s string) { progress.WriteString(s) })
71
+ if err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ if sum.State != "completed" || sum.Text != "kagent echo: hello kagent" || sum.TaskID == "" || sum.ContextID == "" {
75
+ t.Fatalf("%+v", sum)
76
+ }
77
+ got, err := rs.GetTask(context.Background(), "kagent", sum.TaskID)
78
+ if err == nil && got.State != "completed" {
79
+ t.Fatalf("get: %+v", got)
80
+ }
81
+ // Continue the context.
82
+ next, err := rs.Send(context.Background(), SendArgs{Agent: "kagent", Message: "again", ContextID: sum.ContextID}, nil)
83
+ if err != nil || next.ContextID != sum.ContextID || next.TaskID == sum.TaskID {
84
+ t.Fatalf("context continuation: %+v %v", next, err)
85
+ }
86
+ }
87
+
88
+ func TestInterop_KagentCardHostIsNotTrustedWithCredentials(t *testing.T) {
89
+ base := startKagentEcho(t)
90
+ rs := NewRemotes(map[string]RemoteAgent{"kagent": {URL: base, BearerTokenEnv: "KAGENT_TOKEN", TimeoutSeconds: 30}}, envFrom(map[string]string{"KAGENT_TOKEN": "kagent-token-value"}))
91
+ _, err := rs.Send(context.Background(), SendArgs{Agent: "kagent", Message: "hello"}, nil)
92
+ if err == nil || !strings.Contains(err.Error(), "skipCard") {
93
+ t.Fatalf("kagent's card names an in-cluster host; the client must not send a token there: %v", err)
94
+ }
95
+ }
96
+
97
+ func TestInterop_KagentCancelReachesTheRemoteTask(t *testing.T) {
98
+ base := startKagentEcho(t)
99
+ rs := NewRemotes(map[string]RemoteAgent{"kagent": {URL: base, SkipCard: true, TimeoutSeconds: 30}}, envFrom(nil))
100
+ ctx, cancel := context.WithCancel(context.Background())
101
+ errc := make(chan error, 1)
102
+ ids := make(chan string, 1)
103
+ go func() {
104
+ _, err := rs.Send(ctx, SendArgs{Agent: "kagent", Message: "sleep", OnTask: func(id, _ string) { ids <- id }}, nil)
105
+ errc <- err
106
+ }()
107
+ var id string
108
+ select {
109
+ case id = <-ids:
110
+ case <-time.After(10 * time.Second):
111
+ t.Fatal("kagent never reported the task")
112
+ }
113
+ cancel()
114
+ if err := <-errc; err == nil {
115
+ t.Fatal("cancelled Send must fail")
116
+ }
117
+ waitFor(t, func() bool {
118
+ s, err := rs.GetTask(context.Background(), "kagent", id)
119
+ return err == nil && s.State == "canceled"
120
+ }, "the kagent task to be canceled")
121
+ }
122
+
123
+ // kagentHeaders reproduces kagent's own CLI client (go/core/cli/internal/a2a/client.go): an A2A v1
124
+ // client with the JSON-RPC transport, no card resolution, and static headers including A2A-Version.
125
+ type kagentHeaders map[string]string
126
+
127
+ func (h kagentHeaders) Before(ctx context.Context, req *a2aclient.Request) (context.Context, any, error) {
128
+ for k, v := range h {
129
+ req.ServiceParams.Append(k, v)
130
+ }
131
+ return ctx, nil, nil
132
+ }
133
+
134
+ func (kagentHeaders) After(context.Context, *a2aclient.Response) error { return nil }
135
+
136
+ func TestInterop_KagentClientShapeAgainstThisServer(t *testing.T) {
137
+ w := &scriptedWorker{}
138
+ s := startServer(t, serverConfig(), w)
139
+ hc := &http.Client{Transport: bearerTransport{token: tokenA}, Timeout: 30 * time.Second}
140
+ c, err := a2aclient.NewFromEndpoints(context.Background(),
141
+ []*a2a.AgentInterface{{URL: "http://" + s.Addr(), ProtocolVersion: a2a.Version, ProtocolBinding: a2a.TransportProtocolJSONRPC}},
142
+ a2aclient.WithJSONRPCTransport(hc),
143
+ a2aclient.WithCallInterceptors(kagentHeaders{a2a.SvcParamVersion: string(a2a.Version)}))
144
+ if err != nil {
145
+ t.Fatal(err)
146
+ }
147
+ res, err := c.SendMessage(context.Background(), &a2a.SendMessageRequest{Message: textMessage("from a kagent-shaped client")})
148
+ task := mustTask(t, res, err)
149
+ if task.Status.State != a2a.TaskStateCompleted || taskText(task) != "echo: from a kagent-shaped client" {
150
+ t.Fatalf("%s %q", task.Status.State, taskText(task))
151
+ }
152
+ var events int
153
+ for ev, err := range c.SendStreamingMessage(context.Background(), &a2a.SendMessageRequest{Message: textMessage("stream")}) {
154
+ if err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ _ = ev
158
+ events++
159
+ }
160
+ if events < 3 {
161
+ t.Fatalf("a stream has a task, chunks and a final status; got %d events", events)
162
+ }
163
+ }
@@ -0,0 +1,25 @@
1
+ //go:build !windows
2
+
3
+ package a2aext
4
+
5
+ import (
6
+ "errors"
7
+ "os"
8
+ "os/exec"
9
+ "syscall"
10
+ )
11
+
12
+ // configureProcess puts the worker in its own process group so a tool it started
13
+ // (a shell, for instance) dies with it.
14
+ func configureProcess(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} }
15
+
16
+ func terminateTree(p *os.Process) error { return ignoreGone(syscall.Kill(-p.Pid, syscall.SIGTERM)) }
17
+
18
+ func killTree(p *os.Process) error { return ignoreGone(syscall.Kill(-p.Pid, syscall.SIGKILL)) }
19
+
20
+ func ignoreGone(err error) error {
21
+ if errors.Is(err, syscall.ESRCH) || errors.Is(err, os.ErrProcessDone) {
22
+ return nil
23
+ }
24
+ return err
25
+ }
@@ -0,0 +1,26 @@
1
+ //go:build windows
2
+
3
+ package a2aext
4
+
5
+ import (
6
+ "errors"
7
+ "os"
8
+ "os/exec"
9
+ "syscall"
10
+ )
11
+
12
+ const createNewProcessGroup = 0x00000200
13
+
14
+ // configureProcess hides the console window and starts a new process group.
15
+ func configureProcess(cmd *exec.Cmd) {
16
+ cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: createNewProcessGroup}
17
+ }
18
+
19
+ func terminateTree(p *os.Process) error { return killTree(p) }
20
+
21
+ func killTree(p *os.Process) error {
22
+ if err := p.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
23
+ return err
24
+ }
25
+ return nil
26
+ }
@@ -0,0 +1,113 @@
1
+ package a2aext
2
+
3
+ // Stream resubscription while the task is live (roadmap: "stream resubscription ... in both directions").
4
+ // After a restart nothing can be resubscribed: that is TestGap_SubscribeToTaskAfterRestart.
5
+
6
+ import (
7
+ "context"
8
+ "errors"
9
+ "strings"
10
+ "sync"
11
+ "sync/atomic"
12
+ "testing"
13
+ "time"
14
+
15
+ "github.com/a2aproject/a2a-go/v2/a2a"
16
+ )
17
+
18
+ func TestSubscribeToALiveTaskDeliversTheRest(t *testing.T) {
19
+ gate := make(chan struct{})
20
+ started := make(chan struct{})
21
+ w := &scriptedWorker{run: func(ctx context.Context, tn Turn, up func(Update)) (Result, error) {
22
+ up(Update{Text: "part one "})
23
+ close(started)
24
+ select {
25
+ case <-gate:
26
+ case <-ctx.Done():
27
+ return Result{}, ctx.Err()
28
+ }
29
+ up(Update{Text: "part two"})
30
+ return Result{Text: "part one part two"}, nil
31
+ }}
32
+ s := startServer(t, serverConfig(), w)
33
+ base := "http://" + s.Addr()
34
+ first := a2aClient(t, base, tokenA)
35
+
36
+ taskID := make(chan a2a.TaskID, 1)
37
+ go func() {
38
+ sent := false
39
+ for ev, err := range first.SendStreamingMessage(context.Background(), &a2a.SendMessageRequest{Message: textMessage("long")}) {
40
+ if err != nil {
41
+ return
42
+ }
43
+ if !sent {
44
+ taskID <- ev.TaskInfo().TaskID
45
+ sent = true
46
+ }
47
+ }
48
+ }()
49
+ id := <-taskID
50
+ waitChan(t, started, "the worker")
51
+
52
+ // Another caller is not the owner: the task does not exist for them. Their subscription must end with
53
+ // an error and never deliver an event, not even after the task moves on (a2a-go v2.6.0 alone would attach them).
54
+ bob := a2aClient(t, base, tokenB)
55
+ var bobEvents atomic.Int32
56
+ var bobErr atomic.Value
57
+ bobDone := make(chan struct{})
58
+ bobCtx, stopBob := context.WithTimeout(context.Background(), 15*time.Second)
59
+ defer stopBob()
60
+ go func() {
61
+ defer close(bobDone)
62
+ for _, err := range bob.SubscribeToTask(bobCtx, &a2a.SubscribeToTaskRequest{ID: id}) {
63
+ if err != nil {
64
+ bobErr.Store(err)
65
+ return
66
+ }
67
+ bobEvents.Add(1)
68
+ }
69
+ }()
70
+ waitChan(t, bobDone, "the refused subscription to end")
71
+ if bobEvents.Load() != 0 || bobErr.Load() == nil || !errors.Is(bobErr.Load().(error), a2a.ErrTaskNotFound) {
72
+ t.Fatalf("a caller outside the task's tenant subscribed to it: %d events, err %v", bobEvents.Load(), bobErr.Load())
73
+ }
74
+
75
+ // The owner, on a fresh connection, gets the remaining events and the terminal state.
76
+ again := a2aClient(t, base, tokenA)
77
+ var got strings.Builder
78
+ var final a2a.TaskState
79
+ done := make(chan struct{})
80
+ subscribed := make(chan struct{})
81
+ var once sync.Once
82
+ go func() {
83
+ defer close(done)
84
+ for ev, err := range again.SubscribeToTask(context.Background(), &a2a.SubscribeToTaskRequest{ID: id}) {
85
+ if err != nil {
86
+ t.Logf("subscribe error: %v", err)
87
+ return
88
+ }
89
+ switch e := ev.(type) {
90
+ case *a2a.Task: // the snapshot the subscription starts with
91
+ for _, art := range e.Artifacts {
92
+ for _, p := range art.Parts {
93
+ got.WriteString(p.Text())
94
+ }
95
+ }
96
+ final = e.Status.State
97
+ case *a2a.TaskArtifactUpdateEvent:
98
+ for _, p := range e.Artifact.Parts {
99
+ got.WriteString(p.Text())
100
+ }
101
+ case *a2a.TaskStatusUpdateEvent:
102
+ final = e.Status.State
103
+ }
104
+ once.Do(func() { close(subscribed) }) // the snapshot proves the subscription is live
105
+ }
106
+ }()
107
+ waitChan(t, subscribed, "the subscription's first event")
108
+ close(gate)
109
+ waitChan(t, done, "the subscription to end")
110
+ if final != a2a.TaskStateCompleted || !strings.Contains(got.String(), "part two") {
111
+ t.Fatalf("final %s, text %q", final, got.String())
112
+ }
113
+ }