@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.
- package/CREDITS.md +26 -0
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/extensions/a2a/auth.go +120 -0
- package/extensions/a2a/auth_test.go +133 -0
- package/extensions/a2a/bench_test.go +70 -0
- package/extensions/a2a/binary_test.go +225 -0
- package/extensions/a2a/cancelqueued_test.go +142 -0
- package/extensions/a2a/card_cache_test.go +42 -0
- package/extensions/a2a/client.go +499 -0
- package/extensions/a2a/client_test.go +402 -0
- package/extensions/a2a/config.go +282 -0
- package/extensions/a2a/config_test.go +194 -0
- package/extensions/a2a/e2e_test.go +311 -0
- package/extensions/a2a/executor.go +318 -0
- package/extensions/a2a/extension.go +382 -0
- package/extensions/a2a/extension_test.go +396 -0
- package/extensions/a2a/fakehost_test.go +548 -0
- package/extensions/a2a/fakepig_test.go +212 -0
- package/extensions/a2a/gaps_test.go +59 -0
- package/extensions/a2a/go.mod +15 -0
- package/extensions/a2a/go.sum +14 -0
- package/extensions/a2a/interop_test.go +163 -0
- package/extensions/a2a/procattr_other.go +25 -0
- package/extensions/a2a/procattr_windows.go +26 -0
- package/extensions/a2a/resubscribe_test.go +113 -0
- package/extensions/a2a/review_test.go +293 -0
- package/extensions/a2a/server.go +267 -0
- package/extensions/a2a/server_test.go +792 -0
- package/extensions/a2a/survivors_test.go +302 -0
- package/extensions/a2a/worker.go +388 -0
- package/extensions/a2a/worker_test.go +257 -0
- package/package.json +41 -0
- package/port/PORT.md +126 -0
- package/port/a2a-go-LICENSE +201 -0
- package/port/golden/flag-without-auth-refused.jsonl +5 -0
- package/port/golden/listener-off.jsonl +4 -0
- package/port/golden/send-missing-message.jsonl +18 -0
- package/port/golden/send-without-remotes.jsonl +18 -0
- package/port/golden/task-unknown-action.jsonl +18 -0
- package/port/golden/tools-visible-to-model.jsonl +11 -0
- package/port/interop/kagent/main.go +73 -0
- package/port/mutation-run.txt +222 -0
- package/port/mutations.json +656 -0
- package/port/red.txt +105 -0
- package/port/scenarios/flag-without-auth-refused.json +8 -0
- package/port/scenarios/listener-off.json +7 -0
- package/port/scenarios/send-missing-message.json +11 -0
- package/port/scenarios/send-without-remotes.json +11 -0
- package/port/scenarios/task-unknown-action.json +11 -0
- package/port/scenarios/tools-visible-to-model.json +8 -0
- 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
|
+
}
|