@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,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
|
+
}
|