@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,396 @@
1
+ package a2aext_test
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "net"
7
+ "net/http"
8
+ "os"
9
+ "path/filepath"
10
+ "regexp"
11
+ "strings"
12
+ "sync/atomic"
13
+ "testing"
14
+ "time"
15
+
16
+ "github.com/a2aproject/a2a-go/v2/a2a"
17
+ "github.com/a2aproject/a2a-go/v2/a2aclient"
18
+
19
+ a2aext "github.com/MichaelKinsy/pigpen/a2a"
20
+ )
21
+
22
+ const (
23
+ extTokenA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
24
+ extTokenB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
25
+ )
26
+
27
+ type echoWorker struct{ runs atomic.Int32 }
28
+
29
+ func (w *echoWorker) Run(ctx context.Context, t a2aext.Turn, up func(a2aext.Update)) (a2aext.Result, error) {
30
+ w.runs.Add(1)
31
+ up(a2aext.Update{Text: "pong: " + t.Prompt})
32
+ return a2aext.Result{Text: "pong: " + t.Prompt}, nil
33
+ }
34
+
35
+ func mapEnv(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
36
+
37
+ // hostWith starts the extension against the fake host with a config file and environment.
38
+ func hostWith(t *testing.T, config string, env map[string]string, w a2aext.Worker) *Host {
39
+ t.Helper()
40
+ dir := t.TempDir()
41
+ if env == nil {
42
+ env = map[string]string{}
43
+ }
44
+ if config != "" {
45
+ path := filepath.Join(dir, "a2a.json")
46
+ if err := os.WriteFile(path, []byte(config), 0o600); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ env["PIG_A2A_CONFIG"] = path
50
+ }
51
+ env["PIG_CODING_AGENT_DIR"] = dir
52
+ return StartHost(t, a2aext.ExtensionWith(a2aext.Options{Getenv: mapEnv(env), Worker: w}), HostOptions{Mode: "rpc"})
53
+ }
54
+
55
+ func notifications(h *Host) []string {
56
+ var out []string
57
+ for _, c := range h.CallsTo("ui.notify") {
58
+ out = append(out, c.Args["level"].(string)+": "+c.Args["message"].(string))
59
+ }
60
+ return out
61
+ }
62
+
63
+ var listenRE = regexp.MustCompile(`127\.0\.0\.1:\d+`)
64
+
65
+ func listeningAddr(t *testing.T, h *Host) string {
66
+ t.Helper()
67
+ h.Command("a2a", "")
68
+ for _, n := range notifications(h) {
69
+ if m := listenRE.FindString(n); m != "" && strings.Contains(n, "listening") {
70
+ return m
71
+ }
72
+ }
73
+ t.Fatalf("no listening address in %v", notifications(h))
74
+ return ""
75
+ }
76
+
77
+ func canDial(addr string) bool {
78
+ c, err := net.DialTimeout("tcp", addr, 300*time.Millisecond)
79
+ if err != nil {
80
+ return false
81
+ }
82
+ c.Close()
83
+ return true
84
+ }
85
+
86
+ func TestRegistersToolsCommandAndLifecycleHandlers(t *testing.T) {
87
+ h := hostWith(t, "", nil, nil)
88
+ for _, tool := range []string{"a2a_agents", "a2a_send", "a2a_task"} {
89
+ if !h.tools[tool] {
90
+ t.Errorf("tool %s is not registered", tool)
91
+ }
92
+ }
93
+ if !h.cmds["a2a"] {
94
+ t.Error("command /a2a is not registered")
95
+ }
96
+ for _, ev := range []string{"session_start", "session_shutdown"} {
97
+ if !h.Registered(ev) {
98
+ t.Errorf("no %s handler", ev)
99
+ }
100
+ }
101
+ }
102
+
103
+ func TestNoConfigurationOpensNoListener(t *testing.T) {
104
+ h := hostWith(t, "", nil, nil)
105
+ h.Fire("session_start", map[string]any{"reason": "startup"})
106
+ h.Command("a2a", "")
107
+ got := strings.Join(notifications(h), "\n")
108
+ if !strings.Contains(got, "off") || strings.Contains(got, "listening") {
109
+ t.Fatalf("status %q", got)
110
+ }
111
+ }
112
+
113
+ func TestConfiguredListenerServesAndShutdownStopsIt(t *testing.T) {
114
+ w := &echoWorker{}
115
+ h := hostWith(t, `{"listen":"127.0.0.1:0","tokens":[{"name":"ci","tokenEnv":"CI_TOKEN"}]}`, map[string]string{"CI_TOKEN": extTokenA}, w)
116
+ h.Fire("session_start", map[string]any{"reason": "startup"})
117
+ addr := listeningAddr(t, h)
118
+ resp, err := http.Get("http://" + addr + "/.well-known/agent-card.json")
119
+ if err != nil || resp.StatusCode != 200 {
120
+ t.Fatalf("card: %v %v", resp, err)
121
+ }
122
+ resp.Body.Close()
123
+ hc := &http.Client{Transport: roundTripper(func(r *http.Request) (*http.Response, error) {
124
+ r = r.Clone(r.Context())
125
+ r.Header.Set("Authorization", "Bearer "+extTokenA)
126
+ return http.DefaultTransport.RoundTrip(r)
127
+ })}
128
+ c, err := a2aclient.NewFromEndpoints(context.Background(), []*a2a.AgentInterface{a2a.NewAgentInterface("http://"+addr, a2a.TransportProtocolJSONRPC)}, a2aclient.WithJSONRPCTransport(hc))
129
+ if err != nil {
130
+ t.Fatal(err)
131
+ }
132
+ res, err := c.SendMessage(context.Background(), &a2a.SendMessageRequest{Message: a2a.NewMessage(a2a.MessageRoleUser, a2a.NewTextPart("ping"))})
133
+ if err != nil {
134
+ t.Fatal(err)
135
+ }
136
+ if task, ok := res.(*a2a.Task); !ok || task.Status.State != a2a.TaskStateCompleted || w.runs.Load() != 1 {
137
+ t.Fatalf("%#v runs %d", res, w.runs.Load())
138
+ }
139
+ h.Fire("session_shutdown", map[string]any{"reason": "quit"})
140
+ if canDial(addr) {
141
+ t.Fatal("the listener survived session shutdown")
142
+ }
143
+ }
144
+
145
+ type roundTripper func(*http.Request) (*http.Response, error)
146
+
147
+ func (f roundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
148
+
149
+ func TestSessionSwitchKeepsTheListenerReloadRestartsIt(t *testing.T) {
150
+ h := hostWith(t, `{"listen":"127.0.0.1:0","insecureNoAuth":true}`, nil, &echoWorker{})
151
+ h.Fire("session_start", map[string]any{"reason": "startup"})
152
+ addr := listeningAddr(t, h)
153
+ for _, reason := range []string{"new", "resume", "fork"} {
154
+ h.Fire("session_shutdown", map[string]any{"reason": reason})
155
+ if !canDial(addr) {
156
+ t.Fatalf("a %s session switch must not drop the A2A listener", reason)
157
+ }
158
+ h.Fire("session_start", map[string]any{"reason": reason})
159
+ if !canDial(addr) {
160
+ t.Fatalf("listener gone after %s", reason)
161
+ }
162
+ }
163
+ h.Fire("session_shutdown", map[string]any{"reason": "reload"})
164
+ if canDial(addr) {
165
+ t.Fatal("reload must stop the listener so new configuration applies")
166
+ }
167
+ }
168
+
169
+ func TestInvalidConfigurationIsReportedNotFatal(t *testing.T) {
170
+ h := hostWith(t, `{"listen":"0.0.0.0:9999"}`, nil, nil)
171
+ h.Fire("session_start", map[string]any{"reason": "startup"}) // a handler error would fail the test
172
+ got := strings.Join(notifications(h), "\n")
173
+ if !strings.Contains(got, "error:") || !strings.Contains(got, "a2a") {
174
+ t.Fatalf("the user must be told why the listener is off: %q", got)
175
+ }
176
+ if canDial("127.0.0.1:9999") {
177
+ t.Fatal("must not listen with invalid configuration")
178
+ }
179
+ }
180
+
181
+ func TestWorkerChildNeverListens(t *testing.T) {
182
+ h := hostWith(t, `{"listen":"127.0.0.1:0","insecureNoAuth":true}`, map[string]string{"PIG_A2A_WORKER": "1"}, &echoWorker{})
183
+ h.Fire("session_start", map[string]any{"reason": "startup"})
184
+ h.Command("a2a", "")
185
+ if got := strings.Join(notifications(h), "\n"); strings.Contains(got, "listening") {
186
+ t.Fatalf("a worker child opened a listener: %q", got)
187
+ }
188
+ }
189
+
190
+ // remote starts this package's own server as the peer the tools talk to.
191
+ func remote(t *testing.T) (*a2aext.Server, string) {
192
+ t.Helper()
193
+ cfg := a2aext.Config{Listen: "127.0.0.1:0", Name: "peer-pig", MaxConcurrentTasks: 2, TaskTimeoutSeconds: 30,
194
+ Tokens: []a2aext.TokenConfig{{Name: "caller", TokenEnv: "PEER_TOKEN"}}}
195
+ s, err := a2aext.NewServer(cfg, &echoWorker{}, mapEnv(map[string]string{"PEER_TOKEN": extTokenB}))
196
+ if err != nil {
197
+ t.Fatal(err)
198
+ }
199
+ if err := s.Start(); err != nil {
200
+ t.Fatal(err)
201
+ }
202
+ t.Cleanup(func() {
203
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
204
+ defer cancel()
205
+ _ = s.Shutdown(ctx)
206
+ })
207
+ return s, "http://" + s.Addr()
208
+ }
209
+
210
+ func hostWithRemote(t *testing.T, url string) *Host {
211
+ t.Helper()
212
+ cfg, _ := json.Marshal(map[string]any{"remotes": map[string]any{"peer": map[string]any{"url": url, "bearerTokenEnv": "PEER_TOKEN"}}})
213
+ return hostWith(t, string(cfg), map[string]string{"PEER_TOKEN": extTokenB}, nil)
214
+ }
215
+
216
+ func TestAgentsToolListsRemotesWithTheirCards(t *testing.T) {
217
+ _, url := remote(t)
218
+ h := hostWithRemote(t, url)
219
+ raw, fail := h.Tool("a2a_agents", map[string]any{})
220
+ if fail != "" {
221
+ t.Fatal(fail)
222
+ }
223
+ if s := string(raw); !strings.Contains(s, "peer") || !strings.Contains(s, "peer-pig") {
224
+ t.Fatalf("%s", s)
225
+ }
226
+ }
227
+
228
+ func TestSendToolRunsARemoteTaskEndToEnd(t *testing.T) {
229
+ _, url := remote(t)
230
+ h := hostWithRemote(t, url)
231
+ raw, fail := h.Tool("a2a_send", map[string]any{"agent": "peer", "message": "are you there"})
232
+ if fail != "" {
233
+ t.Fatal(fail)
234
+ }
235
+ s := string(raw)
236
+ for _, want := range []string{"completed", "pong: are you there", "taskId", "contextId"} {
237
+ if !strings.Contains(s, want) {
238
+ t.Errorf("result lacks %q: %s", want, s)
239
+ }
240
+ }
241
+ }
242
+
243
+ func TestTaskToolGetsAndCancels(t *testing.T) {
244
+ _, url := remote(t)
245
+ h := hostWithRemote(t, url)
246
+ raw, _ := h.Tool("a2a_send", map[string]any{"agent": "peer", "message": "first"})
247
+ m := regexp.MustCompile(`taskId[\\"]*:\s*[\\"]*([0-9a-f-]{36})`).FindStringSubmatch(string(raw))
248
+ if m == nil {
249
+ t.Fatalf("no task id in %s", raw)
250
+ }
251
+ got, fail := h.Tool("a2a_task", map[string]any{"agent": "peer", "taskId": m[1], "action": "get"})
252
+ if fail != "" || !strings.Contains(string(got), "completed") {
253
+ t.Fatalf("%s %s", got, fail)
254
+ }
255
+ _, fail = h.Tool("a2a_task", map[string]any{"agent": "peer", "taskId": m[1], "action": "cancel"})
256
+ if fail == "" {
257
+ t.Fatal("cancelling a finished task is an error the model should see")
258
+ }
259
+ }
260
+
261
+ func TestToolsRejectBadArguments(t *testing.T) {
262
+ _, url := remote(t)
263
+ h := hostWithRemote(t, url)
264
+ for name, params := range map[string]map[string]any{
265
+ "unknown agent": {"agent": "nobody", "message": "x y"},
266
+ "missing message": {"agent": "peer"},
267
+ "empty message": {"agent": "peer", "message": " "},
268
+ } {
269
+ want := map[string]string{"unknown agent": "unknown agent", "missing message": "message is required", "empty message": "must not be empty"}[name]
270
+ if _, fail := h.Tool("a2a_send", params); !strings.Contains(fail, want) {
271
+ t.Errorf("%s: want a tool error saying %q, got %q", name, want, fail)
272
+ }
273
+ }
274
+ if _, fail := h.Tool("a2a_task", map[string]any{"agent": "peer", "taskId": "x", "action": "explode"}); fail == "" {
275
+ t.Error("unknown action")
276
+ }
277
+ }
278
+
279
+ func TestToolsWithoutRemotesSayWhy(t *testing.T) {
280
+ h := hostWith(t, "", nil, nil)
281
+ _, fail := h.Tool("a2a_send", map[string]any{"agent": "peer", "message": "x y"})
282
+ if fail == "" || !strings.Contains(fail, "remotes") {
283
+ t.Fatalf("%q", fail)
284
+ }
285
+ }
286
+
287
+ // hostWithOptions is hostWith with host options (for example an OnCall that answers getFlag).
288
+ func hostWithOptions(t *testing.T, config string, env map[string]string, w a2aext.Worker, opts HostOptions) (*Host, string) {
289
+ t.Helper()
290
+ dir := t.TempDir()
291
+ if env == nil {
292
+ env = map[string]string{}
293
+ }
294
+ if config != "" {
295
+ if err := os.WriteFile(filepath.Join(dir, "a2a.json"), []byte(config), 0o600); err != nil {
296
+ t.Fatal(err)
297
+ }
298
+ }
299
+ env["PIG_CODING_AGENT_DIR"] = dir
300
+ if opts.Mode == "" {
301
+ opts.Mode = "rpc"
302
+ }
303
+ return StartHost(t, a2aext.ExtensionWith(a2aext.Options{Getenv: mapEnv(env), Worker: w}), opts), dir
304
+ }
305
+
306
+ func TestConfigIsReadFromTheAgentDirectory(t *testing.T) { // agent-dir-ignored
307
+ h, _ := hostWithOptions(t, `{"listen":"127.0.0.1:0","insecureNoAuth":true}`, nil, &echoWorker{}, HostOptions{})
308
+ h.Fire("session_start", map[string]any{"reason": "startup"})
309
+ if addr := listeningAddr(t, h); !canDial(addr) {
310
+ t.Fatalf("%s", addr)
311
+ }
312
+ }
313
+
314
+ func TestFlagOverridesTheConfigurationFile(t *testing.T) { // flag-not-read
315
+ h, _ := hostWithOptions(t, `{"insecureNoAuth":true}`, nil, &echoWorker{}, HostOptions{
316
+ OnCall: func(method string, args map[string]any) (map[string]any, string) {
317
+ if method == "getFlag" && args["name"] == "a2a-listen" {
318
+ return map[string]any{"value": "127.0.0.1:0"}, ""
319
+ }
320
+ return nil, ""
321
+ }})
322
+ h.Fire("session_start", map[string]any{"reason": "startup"})
323
+ if addr := listeningAddr(t, h); !canDial(addr) {
324
+ t.Fatalf("%s", addr)
325
+ }
326
+ }
327
+
328
+ func TestReloadAppliesChangedConfiguration(t *testing.T) { // stop-does-not-reload-config
329
+ h, dir := hostWithOptions(t, `{"remotes":{"first":{"url":"http://127.0.0.1:1"}}}`, nil, nil, HostOptions{})
330
+ h.Fire("session_start", map[string]any{"reason": "startup"})
331
+ h.Command("a2a", "")
332
+ if got := strings.Join(notifications(h), "\n"); !strings.Contains(got, "remotes: first") {
333
+ t.Fatalf("%q", got)
334
+ }
335
+ if err := os.WriteFile(filepath.Join(dir, "a2a.json"), []byte(`{"remotes":{"second":{"url":"http://127.0.0.1:1"}}}`), 0o600); err != nil {
336
+ t.Fatal(err)
337
+ }
338
+ h.Fire("session_shutdown", map[string]any{"reason": "reload"})
339
+ h.Fire("session_start", map[string]any{"reason": "reload"})
340
+ before := len(notifications(h))
341
+ h.Command("a2a", "")
342
+ after := strings.Join(notifications(h)[before:], "\n")
343
+ if !strings.Contains(after, "remotes: second") || strings.Contains(after, "first") {
344
+ t.Fatalf("a reload must apply the new configuration: %q", after)
345
+ }
346
+ }
347
+
348
+ func TestListenFailureIsReportedToTheUser(t *testing.T) { // start-failure-silent
349
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
350
+ if err != nil {
351
+ t.Fatal(err)
352
+ }
353
+ defer ln.Close()
354
+ h, _ := hostWithOptions(t, `{"listen":"`+ln.Addr().String()+`","insecureNoAuth":true}`, nil, &echoWorker{}, HostOptions{})
355
+ h.Fire("session_start", map[string]any{"reason": "startup"})
356
+ if got := strings.Join(notifications(h), "\n"); !strings.Contains(got, "error:") || !strings.Contains(got, "listen") {
357
+ t.Fatalf("a busy port must be reported, not swallowed: %q", got)
358
+ }
359
+ }
360
+
361
+ func TestStatusListsRemotes(t *testing.T) { // status-hides-remotes
362
+ _, url := remote(t)
363
+ h := hostWithRemote(t, url)
364
+ h.Command("a2a", "")
365
+ if got := strings.Join(notifications(h), "\n"); !strings.Contains(got, "remotes: peer") {
366
+ t.Fatalf("%q", got)
367
+ }
368
+ }
369
+
370
+ func TestToolAfterShutdownUsesTheRewrittenConfiguration(t *testing.T) { // stop-does-not-reload-config
371
+ h, dir := hostWithOptions(t, `{"remotes":{"first":{"url":"http://127.0.0.1:1"}}}`, nil, nil, HostOptions{})
372
+ h.Fire("session_start", map[string]any{"reason": "startup"})
373
+ if raw, _ := h.Tool("a2a_agents", map[string]any{}); !strings.Contains(string(raw), "first") {
374
+ t.Fatalf("%s", raw)
375
+ }
376
+ if err := os.WriteFile(filepath.Join(dir, "a2a.json"), []byte(`{"remotes":{"second":{"url":"http://127.0.0.1:1"}}}`), 0o600); err != nil {
377
+ t.Fatal(err)
378
+ }
379
+ h.Fire("session_shutdown", map[string]any{"reason": "reload"})
380
+ raw, _ := h.Tool("a2a_agents", map[string]any{}) // no session_start in between
381
+ if s := string(raw); !strings.Contains(s, "second") || strings.Contains(s, "first") {
382
+ t.Fatalf("a shutdown must drop the cached configuration: %s", s)
383
+ }
384
+ }
385
+
386
+ func TestSendToolPassesTheContextIDThrough(t *testing.T) { // send-drops-context
387
+ _, url := remote(t)
388
+ h := hostWithRemote(t, url)
389
+ raw, fail := h.Tool("a2a_send", map[string]any{"agent": "peer", "message": "one", "contextId": "ctx-from-the-model"})
390
+ if fail != "" {
391
+ t.Fatal(fail)
392
+ }
393
+ if !strings.Contains(string(raw), "ctx-from-the-model") {
394
+ t.Fatalf("the caller's contextId must reach the remote and come back: %s", raw)
395
+ }
396
+ }