@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,225 @@
|
|
|
1
|
+
package a2aext
|
|
2
|
+
|
|
3
|
+
// The Piglet Binary hosts the listener itself: `pig-a2a --mode rpc` with an a2a.json, a fused a2a extension, and a
|
|
4
|
+
// worker that is the same Binary. Set PIG_A2A_BINARY to the executable built from piglets/a2a. Rule 17: a temporary
|
|
5
|
+
// HOME, PIG_HOME and PIG_CODING_AGENT_DIR; the model is a local OpenAI-compatible server.
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"bufio"
|
|
9
|
+
"context"
|
|
10
|
+
"encoding/json"
|
|
11
|
+
"fmt"
|
|
12
|
+
"io"
|
|
13
|
+
"net"
|
|
14
|
+
"net/http"
|
|
15
|
+
"os"
|
|
16
|
+
"os/exec"
|
|
17
|
+
"path/filepath"
|
|
18
|
+
"strings"
|
|
19
|
+
"testing"
|
|
20
|
+
"time"
|
|
21
|
+
|
|
22
|
+
"github.com/a2aproject/a2a-go/v2/a2a"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
func binaryHome(t *testing.T, bin string, a2aJSON func(port int, work string) string) (env []string, work string, port int, llm *e2eLLM) {
|
|
26
|
+
t.Helper()
|
|
27
|
+
home := t.TempDir()
|
|
28
|
+
agent := filepath.Join(home, "pig", "agent")
|
|
29
|
+
if err := os.MkdirAll(agent, 0o700); err != nil {
|
|
30
|
+
t.Fatal(err)
|
|
31
|
+
}
|
|
32
|
+
llm = startE2ELLM(t)
|
|
33
|
+
models := map[string]any{"providers": map[string]any{"e2e": map[string]any{
|
|
34
|
+
"baseUrl": llm.baseURL(), "api": "openai-completions", "apiKey": "e2e-key",
|
|
35
|
+
"models": []any{map[string]any{"id": "e2e-1", "name": "e2e-1", "reasoning": false, "input": []string{"text"},
|
|
36
|
+
"contextWindow": 100000, "maxTokens": 4096, "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}},
|
|
37
|
+
}}}
|
|
38
|
+
b, _ := json.Marshal(models)
|
|
39
|
+
if err := os.WriteFile(filepath.Join(agent, "models.json"), b, 0o600); err != nil {
|
|
40
|
+
t.Fatal(err)
|
|
41
|
+
}
|
|
42
|
+
work = filepath.Join(home, "workspace")
|
|
43
|
+
if err := os.MkdirAll(work, 0o700); err != nil {
|
|
44
|
+
t.Fatal(err)
|
|
45
|
+
}
|
|
46
|
+
if err := os.WriteFile(filepath.Join(work, "note.txt"), []byte("hello from the workspace"), 0o600); err != nil {
|
|
47
|
+
t.Fatal(err)
|
|
48
|
+
}
|
|
49
|
+
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
50
|
+
if err != nil {
|
|
51
|
+
t.Fatal(err)
|
|
52
|
+
}
|
|
53
|
+
port = ln.Addr().(*net.TCPAddr).Port
|
|
54
|
+
_ = ln.Close()
|
|
55
|
+
if cfg := a2aJSON(port, work); cfg != "" {
|
|
56
|
+
if err := os.WriteFile(filepath.Join(agent, "a2a.json"), []byte(cfg), 0o600); err != nil {
|
|
57
|
+
t.Fatal(err)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + home, "PIG_HOME=" + filepath.Join(home, "pig"),
|
|
61
|
+
"PIG_CODING_AGENT_DIR=" + agent, "PI_CODING_AGENT_DIR=" + agent, "A2A_TOKEN_A=" + tokenA, "A2A_TOKEN_B=" + tokenB}
|
|
62
|
+
return env, work, port, llm
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// binHost is a running Binary in RPC mode: commands go to stdin, JSONL events come back on lines.
|
|
66
|
+
type binHost struct {
|
|
67
|
+
stdin io.Writer
|
|
68
|
+
lines chan string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// status asks the running Binary what /a2a says (a UI notification event) and returns it.
|
|
72
|
+
func (h *binHost) status(t *testing.T) string {
|
|
73
|
+
t.Helper()
|
|
74
|
+
if _, err := io.WriteString(h.stdin, `{"type":"prompt","message":"/a2a"}`+"\n"); err != nil {
|
|
75
|
+
t.Fatal(err)
|
|
76
|
+
}
|
|
77
|
+
deadline := time.After(30 * time.Second)
|
|
78
|
+
for {
|
|
79
|
+
select {
|
|
80
|
+
case l := <-h.lines:
|
|
81
|
+
if strings.Contains(l, "a2a:") && strings.Contains(l, "extension_ui_request") {
|
|
82
|
+
return l
|
|
83
|
+
}
|
|
84
|
+
case <-deadline:
|
|
85
|
+
t.Fatal("the Binary never answered /a2a")
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func startBinaryHost(t *testing.T, bin string, env []string, work string, extra ...string) *binHost {
|
|
91
|
+
t.Helper()
|
|
92
|
+
t.Helper()
|
|
93
|
+
args := append([]string{"--mode", "rpc", "--offline", "--provider", "e2e", "--model", "e2e-1", "--no-session"}, extra...)
|
|
94
|
+
cmd := exec.Command(bin, args...)
|
|
95
|
+
cmd.Dir, cmd.Env = work, env
|
|
96
|
+
stdin, err := cmd.StdinPipe()
|
|
97
|
+
if err != nil {
|
|
98
|
+
t.Fatal(err)
|
|
99
|
+
}
|
|
100
|
+
stdout, _ := cmd.StdoutPipe()
|
|
101
|
+
cmd.Stderr = io.Discard
|
|
102
|
+
if err := cmd.Start(); err != nil {
|
|
103
|
+
t.Fatal(err)
|
|
104
|
+
}
|
|
105
|
+
h := &binHost{stdin: stdin, lines: make(chan string, 256)}
|
|
106
|
+
go func() {
|
|
107
|
+
sc := bufio.NewScanner(stdout)
|
|
108
|
+
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
|
109
|
+
for sc.Scan() {
|
|
110
|
+
select {
|
|
111
|
+
case h.lines <- sc.Text():
|
|
112
|
+
default:
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}()
|
|
116
|
+
t.Cleanup(func() {
|
|
117
|
+
_ = stdin.Close()
|
|
118
|
+
done := make(chan struct{})
|
|
119
|
+
go func() { _ = cmd.Wait(); close(done) }()
|
|
120
|
+
select {
|
|
121
|
+
case <-done:
|
|
122
|
+
case <-time.After(10 * time.Second):
|
|
123
|
+
_ = cmd.Process.Kill()
|
|
124
|
+
<-done
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
return h
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func waitDial(t *testing.T, addr string, want bool, d time.Duration) bool {
|
|
131
|
+
t.Helper()
|
|
132
|
+
deadline := time.Now().Add(d)
|
|
133
|
+
for time.Now().Before(deadline) {
|
|
134
|
+
c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
|
135
|
+
if err == nil {
|
|
136
|
+
_ = c.Close()
|
|
137
|
+
if want {
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
} else if !want {
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
time.Sleep(100 * time.Millisecond)
|
|
144
|
+
}
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func TestBinary_ListenerInsideThePigletBinary(t *testing.T) {
|
|
149
|
+
bin := os.Getenv("PIG_A2A_BINARY")
|
|
150
|
+
if bin == "" {
|
|
151
|
+
t.Skip("set PIG_A2A_BINARY to the Piglet Binary built from piglets/a2a to run the Binary proof")
|
|
152
|
+
}
|
|
153
|
+
env, work, port, llm := binaryHome(t, bin, func(port int, work string) string {
|
|
154
|
+
return fmt.Sprintf(`{"listen":"127.0.0.1:%d","name":"binary-pig","tokens":[{"name":"alice","tokenEnv":"A2A_TOKEN_A","tenant":"team-a"},{"name":"bob","tokenEnv":"A2A_TOKEN_B"}],
|
|
155
|
+
"worker":{"command":%q,"cwd":%q,"provider":"e2e","model":"e2e-1","tools":["read","grep","find","ls"]}}`, port, bin, work)
|
|
156
|
+
})
|
|
157
|
+
startBinaryHost(t, bin, env, work)
|
|
158
|
+
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
|
159
|
+
if !waitDial(t, addr, true, 60*time.Second) {
|
|
160
|
+
t.Fatal("the Binary never opened the configured A2A listener")
|
|
161
|
+
}
|
|
162
|
+
base := "http://" + addr
|
|
163
|
+
|
|
164
|
+
// Discovery is public; everything else needs a token.
|
|
165
|
+
resp, err := http.Get(base + "/.well-known/agent-card.json")
|
|
166
|
+
if err != nil || resp.StatusCode != 200 {
|
|
167
|
+
t.Fatalf("card: %v %v", resp, err)
|
|
168
|
+
}
|
|
169
|
+
var card map[string]any
|
|
170
|
+
_ = json.NewDecoder(resp.Body).Decode(&card)
|
|
171
|
+
resp.Body.Close()
|
|
172
|
+
if card["name"] != "binary-pig" {
|
|
173
|
+
t.Fatalf("card %v", card)
|
|
174
|
+
}
|
|
175
|
+
if resp, err = http.Post(base+"/", "application/json", strings.NewReader(`{}`)); err != nil || resp.StatusCode != 401 {
|
|
176
|
+
t.Fatalf("an unauthenticated call must be 401: %v %v", resp, err)
|
|
177
|
+
}
|
|
178
|
+
resp.Body.Close()
|
|
179
|
+
|
|
180
|
+
// A task runs a real PiG worker (the same Binary) that calls a read tool (named in a2a.json) against the local model.
|
|
181
|
+
ca := a2aClient(t, base, tokenA)
|
|
182
|
+
task := sendTask(t, ca, &a2a.SendMessageRequest{Message: textMessage("please read the note")})
|
|
183
|
+
if task.Status.State != a2a.TaskStateCompleted || !strings.Contains(taskText(task), "hello from the workspace") {
|
|
184
|
+
t.Fatalf("state %s: %q %+v", task.Status.State, taskText(task), task.Status.Message)
|
|
185
|
+
}
|
|
186
|
+
llm.mu.Lock()
|
|
187
|
+
for _, tools := range llm.tools {
|
|
188
|
+
if strings.Join(tools, ",") != "find,grep,ls,read" {
|
|
189
|
+
t.Fatalf("the worker must get exactly the configured tools; the model was offered %v", tools)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
llm.mu.Unlock()
|
|
193
|
+
|
|
194
|
+
// The worker was started without extensions, so it did not open a second listener, and another tenant cannot see the task.
|
|
195
|
+
cb := a2aClient(t, base, tokenB)
|
|
196
|
+
if _, err := cb.GetTask(context.Background(), &a2a.GetTaskRequest{ID: task.ID}); err == nil {
|
|
197
|
+
t.Fatal("bob read alice's task")
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func TestBinary_ListenerStaysOffWithoutConfiguration(t *testing.T) {
|
|
202
|
+
bin := os.Getenv("PIG_A2A_BINARY")
|
|
203
|
+
if bin == "" {
|
|
204
|
+
t.Skip("set PIG_A2A_BINARY to the Piglet Binary built from piglets/a2a to run the Binary proof")
|
|
205
|
+
}
|
|
206
|
+
env, work, port, _ := binaryHome(t, bin, func(int, string) string { return "" })
|
|
207
|
+
h := startBinaryHost(t, bin, env, work)
|
|
208
|
+
if got := h.status(t); !strings.Contains(got, "listener off") {
|
|
209
|
+
t.Fatalf("the extension is loaded but /a2a says: %s", got)
|
|
210
|
+
}
|
|
211
|
+
_ = port
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
func TestBinary_FlagWithoutTokensIsRefused(t *testing.T) {
|
|
215
|
+
bin := os.Getenv("PIG_A2A_BINARY")
|
|
216
|
+
if bin == "" {
|
|
217
|
+
t.Skip("set PIG_A2A_BINARY to the Piglet Binary built from piglets/a2a to run the Binary proof")
|
|
218
|
+
}
|
|
219
|
+
env, work, port, _ := binaryHome(t, bin, func(int, string) string { return "" })
|
|
220
|
+
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
|
221
|
+
h := startBinaryHost(t, bin, env, work, "--a2a-listen", addr)
|
|
222
|
+
if got := h.status(t); !strings.Contains(got, "needs at least one entry in tokens") {
|
|
223
|
+
t.Fatalf("--a2a-listen without tokens must be refused with the reason: %s", got)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
package a2aext
|
|
2
|
+
|
|
3
|
+
// A cancel that arrives before the task's worker has started: the task is queued behind the concurrency limit,
|
|
4
|
+
// or behind another task of the same context. It must end canceled without ever running a worker, and must not
|
|
5
|
+
// disturb the task ahead of it.
|
|
6
|
+
|
|
7
|
+
import (
|
|
8
|
+
"context"
|
|
9
|
+
"sync"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/a2aproject/a2a-go/v2/a2a"
|
|
14
|
+
"github.com/a2aproject/a2a-go/v2/a2aclient"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
// streamTask starts a streaming task and returns its id once the server has created it, and a channel
|
|
18
|
+
// carrying the last state seen when the stream ends.
|
|
19
|
+
func streamTask(t *testing.T, c *a2aclient.Client, m *a2a.Message) (a2a.TaskID, <-chan a2a.TaskState) {
|
|
20
|
+
t.Helper()
|
|
21
|
+
ids := make(chan a2a.TaskID, 1)
|
|
22
|
+
last := make(chan a2a.TaskState, 1)
|
|
23
|
+
go func() {
|
|
24
|
+
var state a2a.TaskState
|
|
25
|
+
sent := false
|
|
26
|
+
for ev, err := range c.SendStreamingMessage(context.Background(), &a2a.SendMessageRequest{Message: m}) {
|
|
27
|
+
if err != nil {
|
|
28
|
+
break
|
|
29
|
+
}
|
|
30
|
+
if !sent {
|
|
31
|
+
ids <- ev.TaskInfo().TaskID
|
|
32
|
+
sent = true
|
|
33
|
+
}
|
|
34
|
+
switch e := ev.(type) {
|
|
35
|
+
case *a2a.Task:
|
|
36
|
+
state = e.Status.State
|
|
37
|
+
case *a2a.TaskStatusUpdateEvent:
|
|
38
|
+
state = e.Status.State
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
last <- state
|
|
42
|
+
}()
|
|
43
|
+
select {
|
|
44
|
+
case id := <-ids:
|
|
45
|
+
return id, last
|
|
46
|
+
case <-time.After(10 * time.Second):
|
|
47
|
+
t.Fatal("the server never created the task")
|
|
48
|
+
return "", nil
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func testCancelWhileQueued(t *testing.T, sameContext bool) {
|
|
53
|
+
gate := make(chan struct{})
|
|
54
|
+
started := make(chan struct{}, 4)
|
|
55
|
+
var mu sync.Mutex
|
|
56
|
+
var prompts []string
|
|
57
|
+
w := &scriptedWorker{run: func(ctx context.Context, tn Turn, up func(Update)) (Result, error) {
|
|
58
|
+
mu.Lock()
|
|
59
|
+
prompts = append(prompts, tn.Prompt)
|
|
60
|
+
mu.Unlock()
|
|
61
|
+
started <- struct{}{}
|
|
62
|
+
select {
|
|
63
|
+
case <-gate:
|
|
64
|
+
return Result{Text: "done " + tn.Prompt}, nil
|
|
65
|
+
case <-ctx.Done():
|
|
66
|
+
return Result{}, ctx.Err()
|
|
67
|
+
}
|
|
68
|
+
}}
|
|
69
|
+
cfg := serverConfig()
|
|
70
|
+
cfg.MaxConcurrentTasks = 1
|
|
71
|
+
if sameContext {
|
|
72
|
+
cfg.MaxConcurrentTasks = 2 // a free slot: the second task waits for the context, not for a slot
|
|
73
|
+
}
|
|
74
|
+
s := startServer(t, cfg, w)
|
|
75
|
+
base := "http://" + s.Addr()
|
|
76
|
+
ca := a2aClient(t, base, tokenA)
|
|
77
|
+
|
|
78
|
+
first := textMessage("first")
|
|
79
|
+
first.ContextID = "shared-context"
|
|
80
|
+
firstID, firstLast := streamTask(t, ca, first)
|
|
81
|
+
waitChan(t, chanOf(started), "the first worker")
|
|
82
|
+
|
|
83
|
+
second := textMessage("second")
|
|
84
|
+
if sameContext {
|
|
85
|
+
second.ContextID = "shared-context"
|
|
86
|
+
}
|
|
87
|
+
secondID, secondLast := streamTask(t, ca, second)
|
|
88
|
+
time.Sleep(300 * time.Millisecond) // the second task is queued: the worker has not been asked to run it
|
|
89
|
+
|
|
90
|
+
mu.Lock()
|
|
91
|
+
if len(prompts) != 1 {
|
|
92
|
+
mu.Unlock()
|
|
93
|
+
t.Fatalf("the queued task started a worker: %v", prompts)
|
|
94
|
+
}
|
|
95
|
+
mu.Unlock()
|
|
96
|
+
|
|
97
|
+
got, err := ca.CancelTask(context.Background(), &a2a.CancelTaskRequest{ID: secondID})
|
|
98
|
+
if err != nil {
|
|
99
|
+
t.Fatalf("cancelling a queued task: %v", err)
|
|
100
|
+
}
|
|
101
|
+
if got.Status.State != a2a.TaskStateCanceled {
|
|
102
|
+
t.Fatalf("queued task ended %s", got.Status.State)
|
|
103
|
+
}
|
|
104
|
+
select {
|
|
105
|
+
case st := <-secondLast:
|
|
106
|
+
if st != a2a.TaskStateCanceled {
|
|
107
|
+
t.Fatalf("the queued task's stream ended in %s", st)
|
|
108
|
+
}
|
|
109
|
+
case <-time.After(10 * time.Second):
|
|
110
|
+
t.Fatal("the queued task's stream never ended")
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The first task is untouched and finishes normally; the cancelled one never reached a worker.
|
|
114
|
+
close(gate)
|
|
115
|
+
select {
|
|
116
|
+
case st := <-firstLast:
|
|
117
|
+
if st != a2a.TaskStateCompleted {
|
|
118
|
+
t.Fatalf("the first task ended %s", st)
|
|
119
|
+
}
|
|
120
|
+
case <-time.After(10 * time.Second):
|
|
121
|
+
t.Fatal("the first task never finished")
|
|
122
|
+
}
|
|
123
|
+
mu.Lock()
|
|
124
|
+
defer mu.Unlock()
|
|
125
|
+
if len(prompts) != 1 || prompts[0] != "first" {
|
|
126
|
+
t.Fatalf("workers that ran: %v (task %s, cancelled %s)", prompts, firstID, secondID)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func chanOf(c chan struct{}) <-chan struct{} {
|
|
131
|
+
out := make(chan struct{})
|
|
132
|
+
go func() { <-c; close(out) }()
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
func TestCancelBeforeTheWorkerStartsBehindTheConcurrencyLimit(t *testing.T) {
|
|
137
|
+
testCancelWhileQueued(t, false)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func TestCancelBeforeTheWorkerStartsBehindTheSameContext(t *testing.T) {
|
|
141
|
+
testCancelWhileQueued(t, true)
|
|
142
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
package a2aext
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"net/http/httptest"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
type discardWriter struct{ h http.Header }
|
|
10
|
+
|
|
11
|
+
func (d *discardWriter) Header() http.Header { return d.h }
|
|
12
|
+
func (d *discardWriter) Write(p []byte) (int, error) { return len(p), nil }
|
|
13
|
+
func (d *discardWriter) WriteHeader(int) {}
|
|
14
|
+
|
|
15
|
+
// The Agent Card is the same document for every request, so it is built and encoded once per advertised URL, not
|
|
16
|
+
// on every GET: it used to cost a card build, a JSON encoding and a handler for each request, 11 KB and 67 allocations.
|
|
17
|
+
func TestAgentCardIsEncodedOncePerAdvertisedURL(t *testing.T) {
|
|
18
|
+
s, err := NewServer(serverConfig(), &scriptedWorker{}, serverEnv)
|
|
19
|
+
if err != nil {
|
|
20
|
+
t.Fatal(err)
|
|
21
|
+
}
|
|
22
|
+
r := httptest.NewRequest(http.MethodGet, "http://x/.well-known/agent-card.json", nil)
|
|
23
|
+
w := &discardWriter{h: http.Header{}}
|
|
24
|
+
s.serveCard(w, r) // warm
|
|
25
|
+
if allocs := testing.AllocsPerRun(50, func() { s.serveCard(w, r) }); allocs > 25 {
|
|
26
|
+
t.Fatalf("serving the card allocated %.0f times per request; it must be served from a cached encoding", allocs)
|
|
27
|
+
}
|
|
28
|
+
first := httptest.NewRecorder()
|
|
29
|
+
s.serveCard(first, r)
|
|
30
|
+
second := httptest.NewRecorder()
|
|
31
|
+
s.serveCard(second, r)
|
|
32
|
+
if first.Body.String() != second.Body.String() || first.Body.Len() == 0 {
|
|
33
|
+
t.Fatal("the card must be identical across requests")
|
|
34
|
+
}
|
|
35
|
+
// A different advertised URL (the listener's address changes when it starts) must not serve the old document.
|
|
36
|
+
s.cfg.ExternalURL = "https://agent.example.com"
|
|
37
|
+
third := httptest.NewRecorder()
|
|
38
|
+
s.serveCard(third, r)
|
|
39
|
+
if third.Body.String() == first.Body.String() {
|
|
40
|
+
t.Fatal("the card must follow the advertised URL")
|
|
41
|
+
}
|
|
42
|
+
}
|