@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,194 @@
1
+ package a2aext
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ func envFrom(m map[string]string) func(string) string {
11
+ return func(k string) string { return m[k] }
12
+ }
13
+
14
+ func writeConfig(t *testing.T, dir, body string) {
15
+ t.Helper()
16
+ if err := os.WriteFile(filepath.Join(dir, "a2a.json"), []byte(body), 0o600); err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ }
20
+
21
+ func TestListenerIsOffUnlessConfigured(t *testing.T) {
22
+ cfg, err := LoadConfig(LoadOptions{ConfigHome: t.TempDir(), Getenv: envFrom(nil)})
23
+ if err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ if cfg.Enabled() || cfg.Listen != "" {
27
+ t.Fatalf("listener must be off by default, got %+v", cfg)
28
+ }
29
+ }
30
+
31
+ func TestConfigFileEnablesListener(t *testing.T) {
32
+ dir := t.TempDir()
33
+ writeConfig(t, dir, `{"listen":"127.0.0.1:9911","tokens":[{"name":"ci","tokenEnv":"CI_TOKEN"}]}`)
34
+ cfg, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(map[string]string{"CI_TOKEN": "s3cret-token-value"})})
35
+ if err != nil {
36
+ t.Fatal(err)
37
+ }
38
+ if cfg.Listen != "127.0.0.1:9911" || !cfg.Enabled() {
39
+ t.Fatalf("listen = %q", cfg.Listen)
40
+ }
41
+ }
42
+
43
+ func TestPrecedenceFlagOverEnvOverFile(t *testing.T) {
44
+ dir := t.TempDir()
45
+ writeConfig(t, dir, `{"listen":"127.0.0.1:1111","insecureNoAuth":true}`)
46
+ env := envFrom(map[string]string{"PIG_A2A_LISTEN": "127.0.0.1:2222"})
47
+ cfg, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: env})
48
+ if err != nil || cfg.Listen != "127.0.0.1:2222" {
49
+ t.Fatalf("env over file: %q %v", cfg.Listen, err)
50
+ }
51
+ cfg, err = LoadConfig(LoadOptions{ConfigHome: dir, Getenv: env, FlagListen: "127.0.0.1:3333"})
52
+ if err != nil || cfg.Listen != "127.0.0.1:3333" {
53
+ t.Fatalf("flag over env: %q %v", cfg.Listen, err)
54
+ }
55
+ }
56
+
57
+ func TestConfigPathFromEnv(t *testing.T) {
58
+ dir := t.TempDir()
59
+ path := filepath.Join(dir, "elsewhere.json")
60
+ if err := os.WriteFile(path, []byte(`{"listen":"127.0.0.1:4444","insecureNoAuth":true}`), 0o600); err != nil {
61
+ t.Fatal(err)
62
+ }
63
+ cfg, err := LoadConfig(LoadOptions{ConfigHome: t.TempDir(), Getenv: envFrom(map[string]string{"PIG_A2A_CONFIG": path})})
64
+ if err != nil || cfg.Listen != "127.0.0.1:4444" {
65
+ t.Fatalf("got %q, %v", cfg.Listen, err)
66
+ }
67
+ }
68
+
69
+ func TestInlineSecretsAreRejected(t *testing.T) {
70
+ for name, body := range map[string]string{
71
+ "token": `{"listen":"127.0.0.1:1","tokens":[{"name":"a","token":"inline"}]}`,
72
+ "bearerToken": `{"remotes":{"x":{"url":"http://h","bearerToken":"inline"}}}`,
73
+ "unknown": `{"listenn":"127.0.0.1:1"}`,
74
+ } {
75
+ t.Run(name, func(t *testing.T) {
76
+ dir := t.TempDir()
77
+ writeConfig(t, dir, body)
78
+ _, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)})
79
+ if err == nil {
80
+ t.Fatal("want an error: secrets are named by environment variable, unknown keys are typos")
81
+ }
82
+ if strings.Contains(err.Error(), "inline") {
83
+ t.Fatalf("error must not echo the secret: %v", err)
84
+ }
85
+ })
86
+ }
87
+ }
88
+
89
+ func TestListenerNeedsAuthentication(t *testing.T) {
90
+ dir := t.TempDir()
91
+ writeConfig(t, dir, `{"listen":"127.0.0.1:5555"}`)
92
+ if _, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)}); err == nil {
93
+ t.Fatal("a listener without tokens must be refused unless insecureNoAuth is set")
94
+ }
95
+ }
96
+
97
+ func TestInsecureNoAuthOnlyOnLoopback(t *testing.T) {
98
+ dir := t.TempDir()
99
+ writeConfig(t, dir, `{"listen":"127.0.0.1:5555","insecureNoAuth":true}`)
100
+ if _, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)}); err != nil {
101
+ t.Fatalf("loopback insecureNoAuth: %v", err)
102
+ }
103
+ for _, addr := range []string{"0.0.0.0:5555", ":5555", "192.168.1.5:5555", "example.com:5555"} {
104
+ writeConfig(t, dir, `{"listen":"`+addr+`","insecureNoAuth":true}`)
105
+ if _, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)}); err == nil {
106
+ t.Fatalf("insecureNoAuth on %s must be refused", addr)
107
+ }
108
+ }
109
+ }
110
+
111
+ func TestTokenEnvMustBeSetAndIsNotEchoed(t *testing.T) {
112
+ dir := t.TempDir()
113
+ writeConfig(t, dir, `{"listen":"127.0.0.1:5555","tokens":[{"name":"a","tokenEnv":"A2A_TOKEN_A"}]}`)
114
+ _, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)})
115
+ if err == nil || !strings.Contains(err.Error(), "A2A_TOKEN_A") {
116
+ t.Fatalf("want an error naming the missing variable, got %v", err)
117
+ }
118
+ }
119
+
120
+ func TestTokenConfigValidation(t *testing.T) {
121
+ for name, body := range map[string]string{
122
+ "duplicate name": `{"listen":"127.0.0.1:1","tokens":[{"name":"a","tokenEnv":"T"},{"name":"a","tokenEnv":"U"}]}`,
123
+ "duplicate value": `{"listen":"127.0.0.1:1","tokens":[{"name":"a","tokenEnv":"T"},{"name":"b","tokenEnv":"T"}]}`,
124
+ "short token": `{"listen":"127.0.0.1:1","tokens":[{"name":"a","tokenEnv":"SHORT"}]}`,
125
+ "bad tenant": `{"listen":"127.0.0.1:1","tokens":[{"name":"a","tokenEnv":"T","tenant":"a/b"}]}`,
126
+ "no name": `{"listen":"127.0.0.1:1","tokens":[{"tokenEnv":"T"}]}`,
127
+ } {
128
+ t.Run(name, func(t *testing.T) {
129
+ dir := t.TempDir()
130
+ writeConfig(t, dir, body)
131
+ env := envFrom(map[string]string{"T": "0123456789abcdef0123456789abcdef", "U": "fedcba9876543210fedcba9876543210", "SHORT": "abc"})
132
+ if _, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: env}); err == nil {
133
+ t.Fatal("want a validation error")
134
+ }
135
+ })
136
+ }
137
+ }
138
+
139
+ func TestListenAddressMustBeHostPort(t *testing.T) {
140
+ dir := t.TempDir()
141
+ writeConfig(t, dir, `{"listen":"not-an-address","insecureNoAuth":true}`)
142
+ if _, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)}); err == nil {
143
+ t.Fatal("want an error for a malformed listen address")
144
+ }
145
+ }
146
+
147
+ func TestRemoteDefaultsAndValidation(t *testing.T) {
148
+ dir := t.TempDir()
149
+ writeConfig(t, dir, `{"remotes":{"kagent":{"url":"http://kagent.local/api/a2a/ns/agent","bearerTokenEnv":"KAGENT_TOKEN","skipCard":true}}}`)
150
+ cfg, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)})
151
+ if err != nil {
152
+ t.Fatalf("remotes without a listener need no server auth: %v", err)
153
+ }
154
+ if cfg.Enabled() || cfg.Remotes["kagent"].URL == "" || !cfg.Remotes["kagent"].SkipCard {
155
+ t.Fatalf("%+v", cfg)
156
+ }
157
+ writeConfig(t, dir, `{"remotes":{"bad name!":{"url":"http://x"}}}`)
158
+ if _, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)}); err == nil {
159
+ t.Fatal("remote names are identifiers")
160
+ }
161
+ writeConfig(t, dir, `{"remotes":{"x":{"url":"ftp://x"}}}`)
162
+ if _, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)}); err == nil {
163
+ t.Fatal("remote URL must be http or https")
164
+ }
165
+ }
166
+
167
+ func TestWorkerDefaultsHaveNoTools(t *testing.T) {
168
+ dir := t.TempDir()
169
+ writeConfig(t, dir, `{"listen":"127.0.0.1:5555","insecureNoAuth":true}`)
170
+ cfg, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(nil)})
171
+ if err != nil {
172
+ t.Fatal(err)
173
+ }
174
+ // PiG's read, grep, find and ls take absolute paths, so even "read-only" tools reach every file the account can
175
+ // read (credentials, other tenants' sessions). The operator names tools explicitly (review finding H1).
176
+ if got := strings.Join(cfg.Worker.Tools, ","); got != "" {
177
+ t.Fatalf("default worker tools = %q, want none", got)
178
+ }
179
+ if cfg.MaxConcurrentTasks < 1 || cfg.TaskTimeoutSeconds < 1 {
180
+ t.Fatalf("limits must default to positive values: %+v", cfg)
181
+ }
182
+ }
183
+
184
+ func TestWorkerProcessDoesNotOpenAListener(t *testing.T) {
185
+ dir := t.TempDir()
186
+ writeConfig(t, dir, `{"listen":"127.0.0.1:5555","insecureNoAuth":true}`)
187
+ cfg, err := LoadConfig(LoadOptions{ConfigHome: dir, Getenv: envFrom(map[string]string{"PIG_A2A_WORKER": "1"})})
188
+ if err != nil {
189
+ t.Fatal(err)
190
+ }
191
+ if cfg.Enabled() {
192
+ t.Fatal("a worker child (PIG_A2A_WORKER=1) must never listen, or a task would start another server")
193
+ }
194
+ }
@@ -0,0 +1,311 @@
1
+ package a2aext
2
+
3
+ // End to end with a real PiG: the ProcessWorker starts the actual `pig --mode rpc` (or a Piglet
4
+ // Binary) against a local OpenAI-compatible server, in a temporary HOME / PIG_HOME /
5
+ // PIG_CODING_AGENT_DIR (rule 17: no real credentials, no ~/.pig). Set PIG_A2A_E2E_BIN to run.
6
+
7
+ import (
8
+ "context"
9
+ "encoding/json"
10
+ "fmt"
11
+ "net"
12
+ "net/http"
13
+ "os"
14
+ "path/filepath"
15
+ "sort"
16
+ "strings"
17
+ "sync"
18
+ "testing"
19
+ "time"
20
+
21
+ "github.com/a2aproject/a2a-go/v2/a2a"
22
+ )
23
+
24
+ type e2eLLM struct {
25
+ ln net.Listener
26
+ mu sync.Mutex
27
+ tools [][]string
28
+ hung chan struct{}
29
+ }
30
+
31
+ func startE2ELLM(t *testing.T) *e2eLLM {
32
+ t.Helper()
33
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
34
+ if err != nil {
35
+ t.Fatal(err)
36
+ }
37
+ l := &e2eLLM{ln: ln, hung: make(chan struct{}, 8)}
38
+ mux := http.NewServeMux()
39
+ mux.HandleFunc("/v1/chat/completions", l.handle)
40
+ srv := &http.Server{Handler: mux}
41
+ go func() { _ = srv.Serve(ln) }()
42
+ t.Cleanup(func() { _ = srv.Close() })
43
+ return l
44
+ }
45
+
46
+ func (l *e2eLLM) baseURL() string { return "http://" + l.ln.Addr().String() + "/v1" }
47
+
48
+ type msg struct {
49
+ Role string `json:"role"`
50
+ Content json.RawMessage `json:"content"`
51
+ }
52
+
53
+ func (m msg) text() string {
54
+ var s string
55
+ if json.Unmarshal(m.Content, &s) == nil {
56
+ return s
57
+ }
58
+ var blocks []struct{ Text string }
59
+ if json.Unmarshal(m.Content, &blocks) == nil {
60
+ var b strings.Builder
61
+ for _, x := range blocks {
62
+ b.WriteString(x.Text)
63
+ }
64
+ return b.String()
65
+ }
66
+ return ""
67
+ }
68
+
69
+ func (l *e2eLLM) handle(w http.ResponseWriter, r *http.Request) {
70
+ var req struct {
71
+ Messages []msg `json:"messages"`
72
+ Tools []struct {
73
+ Function struct{ Name string } `json:"function"`
74
+ } `json:"tools"`
75
+ }
76
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
77
+ http.Error(w, err.Error(), 400)
78
+ return
79
+ }
80
+ var names []string
81
+ for _, t := range req.Tools {
82
+ names = append(names, t.Function.Name)
83
+ }
84
+ sort.Strings(names)
85
+ l.mu.Lock()
86
+ l.tools = append(l.tools, names)
87
+ l.mu.Unlock()
88
+
89
+ var lastUser, lastTool string
90
+ var all strings.Builder
91
+ for _, m := range req.Messages {
92
+ all.WriteString(m.text() + "\n")
93
+ switch m.Role {
94
+ case "user":
95
+ lastUser = m.text()
96
+ case "tool":
97
+ lastTool = m.text()
98
+ }
99
+ }
100
+ w.Header().Set("Content-Type", "text/event-stream")
101
+ fl, _ := w.(http.Flusher)
102
+ send := func(delta map[string]any, finish any) {
103
+ b, _ := json.Marshal(map[string]any{"id": "e2e", "object": "chat.completion.chunk", "created": 1, "model": "e2e-1",
104
+ "choices": []any{map[string]any{"index": 0, "delta": delta, "finish_reason": finish}}})
105
+ fmt.Fprintf(w, "data: %s\n\n", b)
106
+ fl.Flush()
107
+ }
108
+ done := func(finish string) {
109
+ send(map[string]any{}, finish)
110
+ fmt.Fprint(w, "data: {\"id\":\"e2e\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"e2e-1\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":1,\"total_tokens\":2}}\n\ndata: [DONE]\n\n")
111
+ fl.Flush()
112
+ }
113
+ send(map[string]any{"role": "assistant", "content": ""}, nil)
114
+ switch {
115
+ case strings.Contains(lastUser, "HANG"):
116
+ send(map[string]any{"content": "thinking..."}, nil)
117
+ l.hung <- struct{}{}
118
+ <-r.Context().Done() // until pig drops the connection
119
+ return
120
+ case strings.HasPrefix(lastUser, "read the file ") && lastTool == "":
121
+ args, _ := json.Marshal(map[string]any{"path": strings.TrimPrefix(lastUser, "read the file ")})
122
+ send(map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": "call_1", "type": "function",
123
+ "function": map[string]any{"name": "read", "arguments": string(args)}}}}, nil)
124
+ done("tool_calls")
125
+ case strings.HasPrefix(lastUser, "read the file "):
126
+ send(map[string]any{"content": "The file says: " + strings.TrimSpace(lastTool)}, nil)
127
+ done("stop")
128
+ case strings.Contains(lastUser, "read the note") && lastTool == "":
129
+ args, _ := json.Marshal(map[string]any{"path": "note.txt"})
130
+ send(map[string]any{"tool_calls": []any{map[string]any{"index": 0, "id": "call_1", "type": "function",
131
+ "function": map[string]any{"name": "read", "arguments": string(args)}}}}, nil)
132
+ done("tool_calls")
133
+ case strings.Contains(lastUser, "read the note"):
134
+ send(map[string]any{"content": "The note says: " + strings.TrimSpace(lastTool)}, nil)
135
+ done("stop")
136
+ case strings.Contains(lastUser, "what is my number"):
137
+ if strings.Contains(all.String(), "my number is 42") {
138
+ send(map[string]any{"content": "Your number is 42."}, nil)
139
+ } else {
140
+ send(map[string]any{"content": "I do not know your number."}, nil)
141
+ }
142
+ done("stop")
143
+ default:
144
+ send(map[string]any{"content": "Noted: " + lastUser}, nil)
145
+ done("stop")
146
+ }
147
+ }
148
+
149
+ type e2eEnv struct {
150
+ worker *ProcessWorker
151
+ server *Server
152
+ llm *e2eLLM
153
+ state string
154
+ work string
155
+ }
156
+
157
+ func startE2E(t *testing.T) *e2eEnv {
158
+ t.Helper()
159
+ bin := os.Getenv("PIG_A2A_E2E_BIN")
160
+ if bin == "" {
161
+ t.Skip("set PIG_A2A_E2E_BIN to a pig (or Piglet Binary) executable to run the real-PiG end-to-end tests")
162
+ }
163
+ home := t.TempDir()
164
+ agent := filepath.Join(home, "pig", "agent")
165
+ if err := os.MkdirAll(agent, 0o700); err != nil {
166
+ t.Fatal(err)
167
+ }
168
+ llm := startE2ELLM(t)
169
+ models := map[string]any{"providers": map[string]any{"e2e": map[string]any{
170
+ "baseUrl": llm.baseURL(), "api": "openai-completions", "apiKey": "e2e-key",
171
+ "models": []any{map[string]any{"id": "e2e-1", "name": "e2e-1", "reasoning": false, "input": []string{"text"},
172
+ "contextWindow": 100000, "maxTokens": 4096, "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}},
173
+ }}}
174
+ b, _ := json.Marshal(models)
175
+ if err := os.WriteFile(filepath.Join(agent, "models.json"), b, 0o600); err != nil {
176
+ t.Fatal(err)
177
+ }
178
+ work := filepath.Join(home, "workspace")
179
+ if err := os.MkdirAll(work, 0o700); err != nil {
180
+ t.Fatal(err)
181
+ }
182
+ if err := os.WriteFile(filepath.Join(work, "note.txt"), []byte("hello from the workspace"), 0o600); err != nil {
183
+ t.Fatal(err)
184
+ }
185
+ env := map[string]string{
186
+ "PATH": os.Getenv("PATH"), "HOME": home, "PIG_HOME": filepath.Join(home, "pig"), "PIG_CODING_AGENT_DIR": agent,
187
+ "TOKEN_A": tokenA, "TOKEN_B": tokenB,
188
+ }
189
+ getenv := func(k string) string { return env[k] }
190
+ state := filepath.Join(home, "a2a-state")
191
+ w, err := NewProcessWorker(WorkerConfig{Command: bin, Cwd: work, Provider: "e2e", Model: "e2e-1", Tools: []string{"read", "grep", "find", "ls"}, GraceSeconds: 5}, state, getenv)
192
+ if err != nil {
193
+ t.Fatal(err)
194
+ }
195
+ s, err := NewServer(serverConfig(), w, getenv)
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(), 20*time.Second)
204
+ defer cancel()
205
+ _ = s.Shutdown(ctx)
206
+ })
207
+ return &e2eEnv{worker: w, server: s, llm: llm, state: state, work: work}
208
+ }
209
+
210
+ func TestE2E_TaskRunsRealPiGWithAToolCall(t *testing.T) {
211
+ e := startE2E(t)
212
+ c := a2aClient(t, "http://"+e.server.Addr(), tokenA)
213
+ task := sendTask(t, c, &a2a.SendMessageRequest{Message: textMessage("please read the note")})
214
+ if task.Status.State != a2a.TaskStateCompleted {
215
+ t.Fatalf("state %s: %+v", task.Status.State, task.Status.Message)
216
+ }
217
+ if got := taskText(task); !strings.Contains(got, "The note says: hello from the workspace") {
218
+ t.Fatalf("artifact %q", got)
219
+ }
220
+ // The worker offered exactly the read-only tools.
221
+ e.llm.mu.Lock()
222
+ defer e.llm.mu.Unlock()
223
+ for _, tools := range e.llm.tools {
224
+ if strings.Join(tools, ",") != "find,grep,ls,read" {
225
+ t.Fatalf("the model was offered %v; the worker must be read-only by default", tools)
226
+ }
227
+ }
228
+ }
229
+
230
+ func TestE2E_ContextContinuesAcrossTasksAndTenantsAreIsolated(t *testing.T) {
231
+ e := startE2E(t)
232
+ ca := a2aClient(t, "http://"+e.server.Addr(), tokenA)
233
+ cb := a2aClient(t, "http://"+e.server.Addr(), tokenB)
234
+ m1 := textMessage("my number is 42")
235
+ m1.ContextID = "conv-1"
236
+ first := sendTask(t, ca, &a2a.SendMessageRequest{Message: m1})
237
+ if first.Status.State != a2a.TaskStateCompleted {
238
+ t.Fatalf("%s", first.Status.State)
239
+ }
240
+ m2 := textMessage("what is my number")
241
+ m2.ContextID = "conv-1"
242
+ if got := taskText(sendTask(t, ca, &a2a.SendMessageRequest{Message: m2})); !strings.Contains(got, "42") {
243
+ t.Fatalf("the same contextId must continue the PiG session: %q", got)
244
+ }
245
+ m3 := textMessage("what is my number")
246
+ m3.ContextID = "conv-1"
247
+ if got := taskText(sendTask(t, cb, &a2a.SendMessageRequest{Message: m3})); strings.Contains(got, "42") {
248
+ t.Fatalf("another tenant's context leaked into this one: %q", got)
249
+ }
250
+ // Session files: one directory per principal, file names carry the derived id only.
251
+ dirs, _ := filepath.Glob(filepath.Join(e.state, "sessions", "*", "*.jsonl"))
252
+ if len(dirs) != 2 {
253
+ t.Fatalf("want 2 session files (tenant team-a context, token bob context), got %v", dirs)
254
+ }
255
+ for _, f := range dirs {
256
+ if strings.Contains(f, "conv-1") {
257
+ t.Fatalf("context id in a file name: %s", f)
258
+ }
259
+ }
260
+ }
261
+
262
+ func TestE2E_CancelAbortsRealPiG(t *testing.T) {
263
+ e := startE2E(t)
264
+ c := a2aClient(t, "http://"+e.server.Addr(), tokenA)
265
+ var id a2a.TaskID
266
+ events := c.SendStreamingMessage(context.Background(), &a2a.SendMessageRequest{Message: textMessage("HANG please")})
267
+ last := make(chan a2a.TaskState, 1)
268
+ go func() {
269
+ var s a2a.TaskState
270
+ for ev, err := range events {
271
+ if err != nil {
272
+ break
273
+ }
274
+ switch x := ev.(type) {
275
+ case *a2a.Task:
276
+ id, s = x.ID, x.Status.State
277
+ case *a2a.TaskStatusUpdateEvent:
278
+ s = x.Status.State
279
+ }
280
+ }
281
+ last <- s
282
+ }()
283
+ select {
284
+ case <-e.llm.hung:
285
+ case <-time.After(30 * time.Second):
286
+ t.Fatal("the model was never called")
287
+ }
288
+ var tid a2a.TaskID
289
+ waitFor(t, func() bool {
290
+ list, err := c.ListTasks(context.Background(), &a2a.ListTasksRequest{})
291
+ if err == nil && len(list.Tasks) == 1 {
292
+ tid = list.Tasks[0].ID
293
+ return true
294
+ }
295
+ return false
296
+ }, "the task to be listed")
297
+ got, err := c.CancelTask(context.Background(), &a2a.CancelTaskRequest{ID: tid})
298
+ if err != nil || got.Status.State != a2a.TaskStateCanceled {
299
+ t.Fatalf("%v %v", got, err)
300
+ }
301
+ select {
302
+ case s := <-last:
303
+ if s != a2a.TaskStateCanceled {
304
+ t.Fatalf("stream ended in %s", s)
305
+ }
306
+ case <-time.After(20 * time.Second):
307
+ t.Fatal("stream did not end")
308
+ }
309
+ waitFor(t, func() bool { return e.server.ActiveTasks() == 0 }, "the worker to be reaped")
310
+ _ = id
311
+ }