@pi-in-go/pigpen-acp 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 (134) hide show
  1. package/CREDITS.md +23 -0
  2. package/LICENSE +22 -0
  3. package/README.md +110 -0
  4. package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
  5. package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
  6. package/extensions/acp/cmd/pig-acp/go.mod +6 -0
  7. package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
  8. package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
  9. package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
  10. package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
  11. package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
  12. package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
  13. package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
  14. package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
  15. package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
  16. package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
  17. package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
  18. package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
  19. package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
  20. package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
  21. package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
  22. package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
  23. package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
  24. package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
  25. package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
  26. package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
  27. package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
  28. package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
  29. package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
  30. package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
  31. package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
  32. package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
  33. package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
  34. package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
  35. package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
  36. package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
  37. package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
  38. package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
  39. package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
  40. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
  41. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
  42. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
  43. package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
  44. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
  45. package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
  46. package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
  47. package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
  48. package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
  49. package/extensions/acp/cmd/pig-acp/main.go +177 -0
  50. package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
  51. package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
  52. package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
  53. package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
  54. package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
  55. package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
  56. package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
  57. package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
  58. package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
  59. package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
  60. package/extensions/acp/extension.go +63 -0
  61. package/extensions/acp/extension_test.go +111 -0
  62. package/extensions/acp/fakehost_test.go +548 -0
  63. package/extensions/acp/go.mod +6 -0
  64. package/extensions/acp/go.sum +2 -0
  65. package/package.json +41 -0
  66. package/port/PORT.md +147 -0
  67. package/port/interop/package-lock.json +89 -0
  68. package/port/interop/package.json +13 -0
  69. package/port/interop/run.mjs +229 -0
  70. package/port/mutate.mjs +110 -0
  71. package/port/mutation-results.txt +107 -0
  72. package/port/mutations.json +748 -0
  73. package/port/oracle/pi-acp/LICENSE +21 -0
  74. package/port/oracle/pi-acp/README.md +209 -0
  75. package/port/oracle/pi-acp/package.json +65 -0
  76. package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
  77. package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
  78. package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
  79. package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
  80. package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
  81. package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
  82. package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
  83. package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
  84. package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
  85. package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
  86. package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
  87. package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
  88. package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
  89. package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
  90. package/port/oracle/pi-acp/src/index.ts +82 -0
  91. package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
  92. package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
  93. package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
  94. package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
  95. package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
  96. package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
  97. package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
  98. package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
  99. package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
  100. package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
  101. package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
  102. package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
  103. package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
  104. package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
  105. package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
  106. package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
  107. package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
  108. package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
  109. package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
  110. package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
  111. package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
  112. package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
  113. package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
  114. package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
  115. package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
  116. package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
  117. package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
  118. package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
  119. package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
  120. package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
  121. package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
  122. package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
  123. package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
  124. package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
  125. package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
  126. package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
  127. package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
  128. package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
  129. package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
  130. package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
  131. package/port/schema/LICENSE +191 -0
  132. package/port/schema/schema.json +8015 -0
  133. package/port/upstream-tests.json +201 -0
  134. package/provenance.json +28 -0
@@ -0,0 +1,541 @@
1
+ package main
2
+
3
+ // End-to-end: a scripted ACP client drives pig-acp (in process), which starts a real
4
+ // `pig --mode rpc` child that talks to a scripted OpenAI-compatible model. Needs a pig binary:
5
+ // PIG_ACP_E2E_PIG=/path/to/pig go test -run E2E ./...
6
+ // (npm run test:go-ports sets it from PIG_BIN). Every run uses temporary HOME, PIG_HOME and
7
+ // agent directories; nothing reads or writes the real configuration.
8
+
9
+ import (
10
+ "bufio"
11
+ "encoding/json"
12
+ "io"
13
+ "net"
14
+ "os"
15
+ "os/exec"
16
+ "path/filepath"
17
+ "strings"
18
+ "sync"
19
+ "testing"
20
+ "time"
21
+ )
22
+
23
+ func e2ePig(t *testing.T) string {
24
+ t.Helper()
25
+ p := os.Getenv("PIG_ACP_E2E_PIG")
26
+ if p == "" {
27
+ t.Skip("set PIG_ACP_E2E_PIG to a pig binary (or a built Piglet Binary) to run the end-to-end scenarios")
28
+ }
29
+ return p
30
+ }
31
+
32
+ // e2eHome points the process environment at fresh directories and, when llm is set, writes a
33
+ // models.json with a custom provider for it. It returns the working directory for sessions.
34
+ func e2eHome(t *testing.T, llm *fakeLLM) (work string) {
35
+ t.Helper()
36
+ // pig records a session's cwd as the OS resolves it, and session/list matches the cwd exactly (pi-acp does the
37
+ // same), so the working directory must be the resolved path: macOS's TMPDIR is a symlink (/var -> /private/var).
38
+ root, err := filepath.EvalSymlinks(t.TempDir())
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ agent := filepath.Join(root, "agent")
43
+ work = filepath.Join(root, "work")
44
+ for _, d := range []string{agent, work, filepath.Join(root, "home")} {
45
+ if err := os.MkdirAll(d, 0o755); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ }
49
+ t.Setenv("HOME", filepath.Join(root, "home"))
50
+ t.Setenv("PIG_HOME", filepath.Join(root, "pighome"))
51
+ t.Setenv("PIG_CODING_AGENT_DIR", agent)
52
+ t.Setenv("PI_CODING_AGENT_DIR", agent)
53
+ t.Setenv("PIG_OFFLINE", "1")
54
+ t.Setenv("PI_SKIP_VERSION_CHECK", "1")
55
+ t.Setenv("PI_TELEMETRY", "0")
56
+ if llm != nil {
57
+ models := map[string]any{"providers": map[string]any{"acp-llm": map[string]any{
58
+ "baseUrl": llm.baseURL(), "api": "openai-completions", "apiKey": "acp-key",
59
+ "models": []any{map[string]any{"id": "acp-1", "name": "ACP One", "reasoning": false, "input": []string{"text"},
60
+ "contextWindow": 100000, "maxTokens": 4096, "cost": map[string]any{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}},
61
+ }}}
62
+ b, _ := json.Marshal(models)
63
+ if err := os.WriteFile(filepath.Join(agent, "models.json"), b, 0o600); err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ settings, _ := json.Marshal(map[string]any{"defaultProvider": "acp-llm", "defaultModel": "acp-1", "quietStartup": true})
67
+ if err := os.WriteFile(filepath.Join(agent, "settings.json"), settings, 0o600); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ }
71
+ return work
72
+ }
73
+
74
+ // acpClient is a scripted ACP client over the in-process pig-acp.
75
+ type acpClient struct {
76
+ t *testing.T
77
+ in *io.PipeWriter
78
+ mu sync.Mutex
79
+ nextID int
80
+ inbox chan map[string]any
81
+ exit chan int
82
+ seen []map[string]any
83
+ // onRequest answers requests from the agent (session/request_permission).
84
+ onRequest func(method string, params map[string]any) map[string]any
85
+ }
86
+
87
+ func startPigACP(t *testing.T, args ...string) *acpClient {
88
+ t.Helper()
89
+ inR, inW := io.Pipe()
90
+ outR, outW := io.Pipe()
91
+ c := &acpClient{t: t, in: inW, inbox: make(chan map[string]any, 4096), exit: make(chan int, 1)}
92
+ go func() { c.exit <- run(args, inR, outW, os.Stderr); inR.Close(); outW.Close() }()
93
+ go func() {
94
+ sc := bufio.NewScanner(outR)
95
+ sc.Buffer(make([]byte, 1<<20), 1<<26)
96
+ for sc.Scan() {
97
+ var m map[string]any
98
+ if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
99
+ t.Errorf("stdout carries a line that is not JSON: %q", sc.Text())
100
+ continue
101
+ }
102
+ c.mu.Lock()
103
+ c.seen = append(c.seen, m)
104
+ c.mu.Unlock()
105
+ if method, _ := m["method"].(string); method != "" && m["id"] != nil && c.onRequest != nil {
106
+ c.write(map[string]any{"jsonrpc": "2.0", "id": m["id"], "result": c.onRequest(method, m["params"].(map[string]any))})
107
+ }
108
+ c.inbox <- m
109
+ }
110
+ close(c.inbox)
111
+ }()
112
+ t.Cleanup(func() { inW.Close() })
113
+ return c
114
+ }
115
+
116
+ func (c *acpClient) write(v any) {
117
+ b, _ := json.Marshal(v)
118
+ c.mu.Lock()
119
+ defer c.mu.Unlock()
120
+ _, _ = c.in.Write(append(b, '\n'))
121
+ }
122
+
123
+ func (c *acpClient) request(method string, params any) int {
124
+ c.mu.Lock()
125
+ c.nextID++
126
+ id := c.nextID
127
+ c.mu.Unlock()
128
+ c.write(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
129
+ return id
130
+ }
131
+
132
+ func (c *acpClient) notify(method string, params any) {
133
+ c.write(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
134
+ }
135
+
136
+ // await returns the response to id and every notification that arrived before it.
137
+ func (c *acpClient) await(id int) (map[string]any, []map[string]any) {
138
+ c.t.Helper()
139
+ var before []map[string]any
140
+ deadline := time.After(90 * time.Second)
141
+ for {
142
+ select {
143
+ case m, ok := <-c.inbox:
144
+ if !ok {
145
+ c.t.Fatal("pig-acp closed its output")
146
+ }
147
+ if m["id"] == float64(id) && m["method"] == nil {
148
+ return m, before
149
+ }
150
+ before = append(before, m)
151
+ case <-deadline:
152
+ c.t.Fatalf("no response to request %d; saw %d messages", id, len(before))
153
+ }
154
+ }
155
+ }
156
+
157
+ func (c *acpClient) call(method string, params any) map[string]any {
158
+ c.t.Helper()
159
+ r, _ := c.await(c.request(method, params))
160
+ return r
161
+ }
162
+
163
+ func (c *acpClient) result(method string, params any) map[string]any {
164
+ c.t.Helper()
165
+ r := c.call(method, params)
166
+ res, ok := r["result"].(map[string]any)
167
+ if !ok {
168
+ c.t.Fatalf("%s failed: %v", method, r)
169
+ }
170
+ return res
171
+ }
172
+
173
+ func (c *acpClient) updates() []map[string]any {
174
+ c.mu.Lock()
175
+ defer c.mu.Unlock()
176
+ var out []map[string]any
177
+ for _, m := range c.seen {
178
+ if m["method"] == "session/update" {
179
+ out = append(out, m["params"].(map[string]any)["update"].(map[string]any))
180
+ }
181
+ }
182
+ return out
183
+ }
184
+
185
+ func (c *acpClient) untilUpdate(what string, pred func(u map[string]any) bool) map[string]any {
186
+ c.t.Helper()
187
+ deadline := time.Now().Add(60 * time.Second)
188
+ for time.Now().Before(deadline) {
189
+ for _, u := range c.updates() {
190
+ if pred(u) {
191
+ return u
192
+ }
193
+ }
194
+ time.Sleep(20 * time.Millisecond)
195
+ }
196
+ c.t.Fatalf("timed out waiting for %s; updates: %v", what, c.updates())
197
+ return nil
198
+ }
199
+
200
+ func (c *acpClient) initialize() map[string]any {
201
+ return c.result("initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{
202
+ "fs": map[string]any{"readTextFile": true, "writeTextFile": true}, "terminal": true}})
203
+ }
204
+
205
+ func textPrompt(sessionID, text string) map[string]any {
206
+ return map[string]any{"sessionId": sessionID, "prompt": []any{map[string]any{"type": "text", "text": text}}}
207
+ }
208
+
209
+ func chunksText(us []map[string]any, kind string) string {
210
+ var b strings.Builder
211
+ for _, u := range us {
212
+ if u["sessionUpdate"] == kind {
213
+ if c, _ := u["content"].(map[string]any); c["type"] == "text" {
214
+ b.WriteString(c["text"].(string))
215
+ }
216
+ }
217
+ }
218
+ return b.String()
219
+ }
220
+
221
+ func TestE2EPromptStreamsAndReportsUsage(t *testing.T) {
222
+ pig := e2ePig(t)
223
+ llm := startFakeLLM(t, llmTurn{Text: "Hello from the scripted model", Chunks: 3})
224
+ work := e2eHome(t, llm)
225
+ c := startPigACP(t, "--pig", pig)
226
+ c.initialize()
227
+ sess := c.result("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})
228
+ sid, _ := sess["sessionId"].(string)
229
+ if sid == "" {
230
+ t.Fatalf("session/new = %v", sess)
231
+ }
232
+ models, _ := sess["models"].(map[string]any)
233
+ if models["currentModelId"] != "acp-llm/acp-1" {
234
+ t.Errorf("models = %v", models)
235
+ }
236
+ resp, _ := c.await(c.request("session/prompt", textPrompt(sid, "say hello")))
237
+ if resp["result"].(map[string]any)["stopReason"] != "end_turn" {
238
+ t.Fatalf("response = %v", resp)
239
+ }
240
+ us := c.updates()
241
+ if got := chunksText(us, "agent_message_chunk"); !strings.Contains(got, "Hello from the scripted model") {
242
+ t.Errorf("streamed %q", got)
243
+ }
244
+ usage := c.untilUpdate("usage_update with tokens", func(u map[string]any) bool {
245
+ n, _ := u["used"].(float64)
246
+ return u["sessionUpdate"] == "usage_update" && n > 0
247
+ })
248
+ if usage["size"] != float64(100000) {
249
+ t.Errorf("usage = %v", usage)
250
+ }
251
+ if seen := llm.seen(); len(seen) != 1 || seen[0] != "say hello" {
252
+ t.Errorf("the model saw %v", seen)
253
+ }
254
+ c.mu.Lock()
255
+ for _, m := range c.seen {
256
+ if method, _ := m["method"].(string); strings.HasPrefix(method, "fs/") || strings.HasPrefix(method, "terminal/") {
257
+ t.Errorf("pig-acp called %s", method)
258
+ }
259
+ }
260
+ c.mu.Unlock()
261
+ }
262
+
263
+ func TestE2EBashToolCall(t *testing.T) {
264
+ pig := e2ePig(t)
265
+ llm := startFakeLLM(t,
266
+ llmTurn{Calls: []llmCall{{Name: "bash", Args: map[string]any{"command": "echo hi-from-bash"}}}},
267
+ llmTurn{Text: "done"})
268
+ work := e2eHome(t, llm)
269
+ c := startPigACP(t, "--pig", pig)
270
+ c.initialize()
271
+ sid := c.result("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})["sessionId"].(string)
272
+ resp, _ := c.await(c.request("session/prompt", textPrompt(sid, "run it")))
273
+ if resp["result"].(map[string]any)["stopReason"] != "end_turn" {
274
+ t.Fatalf("response = %v", resp)
275
+ }
276
+ var call, last map[string]any
277
+ var output strings.Builder
278
+ for _, u := range c.updates() {
279
+ if u["sessionUpdate"] == "tool_call" && call == nil {
280
+ call = u
281
+ }
282
+ if u["sessionUpdate"] == "tool_call_update" {
283
+ last = u
284
+ if m, _ := u["_meta"].(map[string]any); m != nil {
285
+ if o, ok := m["terminal_output"].(map[string]any); ok {
286
+ output.WriteString(o["data"].(string))
287
+ }
288
+ }
289
+ }
290
+ }
291
+ if call == nil || call["kind"] != "execute" || call["title"] != "echo hi-from-bash" {
292
+ t.Fatalf("tool_call = %v", call)
293
+ }
294
+ if last["status"] != "completed" || !strings.Contains(output.String(), "hi-from-bash") {
295
+ t.Errorf("last=%v output=%q", last, output.String())
296
+ }
297
+ if exit := last["_meta"].(map[string]any)["terminal_exit"].(map[string]any); exit["exit_code"] != float64(0) {
298
+ t.Errorf("terminal_exit = %v", exit)
299
+ }
300
+ }
301
+
302
+ func TestE2EWriteToolCallEmitsDiff(t *testing.T) {
303
+ pig := e2ePig(t)
304
+ if _, err := exec.LookPath("go"); err != nil {
305
+ t.Skip("building the fixture extension needs a Go toolchain")
306
+ }
307
+ llm := startFakeLLM(t,
308
+ llmTurn{Calls: []llmCall{{Name: "write", Args: map[string]any{"path": "new.txt", "content": "created\n"}}}},
309
+ llmTurn{Text: "written"})
310
+ work := e2eHome(t, llm)
311
+ // pig announces a tool (tool_execution_start) and runs it without waiting for the adapter, which
312
+ // reads the file when it sees the announcement: under load the write can land first and there is
313
+ // no old text to diff against. The gate extension holds the write after the announcement until
314
+ // this test has seen the adapter act on it, so the adapter always comes first.
315
+ gate, err := net.Listen("tcp", "127.0.0.1:0")
316
+ if err != nil {
317
+ t.Fatal(err)
318
+ }
319
+ t.Cleanup(func() { _ = gate.Close() })
320
+ if err := os.WriteFile(filepath.Join(work, "gate.addr"), []byte(gate.Addr().String()), 0o644); err != nil {
321
+ t.Fatal(err)
322
+ }
323
+ held := make(chan net.Conn, 1)
324
+ go func() {
325
+ if conn, err := gate.Accept(); err == nil {
326
+ held <- conn
327
+ }
328
+ }()
329
+ fixture, _ := filepath.Abs("testdata/gate")
330
+ c := startPigACP(t, "--pig", pig, "--pig-arg", "-e", "--pig-arg", fixture)
331
+ c.initialize()
332
+ sid := c.result("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})["sessionId"].(string)
333
+ id := c.request("session/prompt", textPrompt(sid, "write a file"))
334
+ conn := <-held // the write is held
335
+ c.untilUpdate("the adapter to report the write in progress", func(u map[string]any) bool {
336
+ return u["sessionUpdate"] == "tool_call_update" && u["status"] == "in_progress"
337
+ })
338
+ _ = conn.Close() // release the write
339
+ c.await(id)
340
+ var diff map[string]any
341
+ for _, u := range c.updates() {
342
+ if u["sessionUpdate"] == "tool_call_update" && u["status"] == "completed" {
343
+ for _, item := range u["content"].([]any) {
344
+ if m := item.(map[string]any); m["type"] == "diff" {
345
+ diff = m
346
+ }
347
+ }
348
+ }
349
+ }
350
+ if diff == nil || diff["path"] != "new.txt" || diff["oldText"] != nil || diff["newText"] != "created\n" {
351
+ t.Fatalf("diff = %v (updates %v)", diff, c.updates())
352
+ }
353
+ if b, _ := os.ReadFile(filepath.Join(work, "new.txt")); string(b) != "created\n" {
354
+ t.Errorf("file = %q", b)
355
+ }
356
+ }
357
+
358
+ func TestE2ECancel(t *testing.T) {
359
+ pig := e2ePig(t)
360
+ hold, started := make(chan struct{}), make(chan struct{})
361
+ llm := startFakeLLM(t, llmTurn{Text: "partial answer that never finishes", Chunks: 4, Hold: hold, Started: started})
362
+ work := e2eHome(t, llm)
363
+ c := startPigACP(t, "--pig", pig)
364
+ c.initialize()
365
+ sid := c.result("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})["sessionId"].(string)
366
+ id := c.request("session/prompt", textPrompt(sid, "go on forever"))
367
+ select {
368
+ case <-started:
369
+ case <-time.After(60 * time.Second):
370
+ t.Fatal("the model was never asked")
371
+ }
372
+ c.notify("session/cancel", map[string]any{"sessionId": sid})
373
+ resp, _ := c.await(id)
374
+ if resp["result"].(map[string]any)["stopReason"] != "cancelled" {
375
+ t.Fatalf("response = %v", resp)
376
+ }
377
+ close(hold)
378
+ }
379
+
380
+ func TestE2ELoadAndListSessions(t *testing.T) {
381
+ pig := e2ePig(t)
382
+ llm := startFakeLLM(t, llmTurn{Text: "first answer"})
383
+ work := e2eHome(t, llm)
384
+ c := startPigACP(t, "--pig", pig)
385
+ c.initialize()
386
+ sid := c.result("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})["sessionId"].(string)
387
+ c.await(c.request("session/prompt", textPrompt(sid, "remember the word pineapple")))
388
+ c.call("session/prompt", textPrompt(sid, "/name Pineapple session"))
389
+ c.in.Close()
390
+ if code := <-c.exit; code != 0 {
391
+ t.Fatalf("first adapter exited %d", code)
392
+ }
393
+
394
+ // A new adapter process (a new editor window) lists and loads the session.
395
+ c2 := startPigACP(t, "--pig", pig)
396
+ c2.initialize()
397
+ list := c2.result("session/list", map[string]any{"cwd": work})
398
+ var found map[string]any
399
+ for _, s := range list["sessions"].([]any) {
400
+ if s.(map[string]any)["sessionId"] == sid {
401
+ found = s.(map[string]any)
402
+ }
403
+ }
404
+ if found == nil || found["title"] != "Pineapple session" {
405
+ t.Fatalf("session/list = %v", list)
406
+ }
407
+ c2.result("session/load", map[string]any{"sessionId": sid, "cwd": work, "mcpServers": []any{}})
408
+ us := c2.updates()
409
+ if !strings.Contains(chunksText(us, "user_message_chunk"), "remember the word pineapple") || !strings.Contains(chunksText(us, "agent_message_chunk"), "first answer") {
410
+ t.Errorf("history not replayed: %v", us)
411
+ }
412
+ if res := c2.call("session/delete", map[string]any{"sessionId": sid}); res["error"] != nil {
413
+ t.Errorf("delete: %v", res)
414
+ }
415
+ list = c2.result("session/list", map[string]any{"cwd": work})
416
+ for _, s := range list["sessions"].([]any) {
417
+ if s.(map[string]any)["sessionId"] == sid {
418
+ t.Error("the deleted session is still listed")
419
+ }
420
+ }
421
+ }
422
+
423
+ func TestE2EConfigOptions(t *testing.T) {
424
+ pig := e2ePig(t)
425
+ llm := startFakeLLM(t)
426
+ work := e2eHome(t, llm)
427
+ c := startPigACP(t, "--pig", pig)
428
+ c.initialize()
429
+ sess := c.result("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})
430
+ sid := sess["sessionId"].(string)
431
+ modes := sess["modes"].(map[string]any)
432
+ if modes["currentModeId"] == "" || len(modes["availableModes"].([]any)) == 0 {
433
+ t.Fatalf("modes = %v", modes)
434
+ }
435
+ res := c.result("session/set_config_option", map[string]any{"sessionId": sid, "configId": "model", "value": "acp-llm/acp-1"})
436
+ if len(res["configOptions"].([]any)) < 2 {
437
+ t.Errorf("configOptions = %v", res)
438
+ }
439
+ if r := c.call("session/set_config_option", map[string]any{"sessionId": sid, "configId": "model", "value": "acp-llm/missing"}); r["error"] == nil {
440
+ t.Errorf("an unknown model was accepted: %v", r)
441
+ }
442
+ c.untilUpdate("config_option_update", func(u map[string]any) bool { return u["sessionUpdate"] == "config_option_update" })
443
+ }
444
+
445
+ func TestE2EExtensionSelectBecomesPermissionRequest(t *testing.T) {
446
+ pig := e2ePig(t)
447
+ if _, err := exec.LookPath("go"); err != nil {
448
+ t.Skip("building the fixture extension needs a Go toolchain")
449
+ }
450
+ llm := startFakeLLM(t,
451
+ llmTurn{Calls: []llmCall{{Name: "ask", Args: map[string]any{}}}},
452
+ llmTurn{Text: "deployed"})
453
+ work := e2eHome(t, llm)
454
+ fixture, _ := filepath.Abs("testdata/ask")
455
+ c := startPigACP(t, "--pig", pig, "--pig-arg", "-e", "--pig-arg", fixture)
456
+ var asked map[string]any
457
+ c.onRequest = func(method string, params map[string]any) map[string]any {
458
+ if method != "session/request_permission" {
459
+ return map[string]any{}
460
+ }
461
+ asked = params
462
+ return map[string]any{"outcome": map[string]any{"outcome": "selected", "optionId": "choice-1"}}
463
+ }
464
+ c.initialize()
465
+ sid := c.result("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})["sessionId"].(string)
466
+ resp, _ := c.await(c.request("session/prompt", textPrompt(sid, "deploy it")))
467
+ if resp["result"] == nil {
468
+ t.Fatalf("response = %v", resp)
469
+ }
470
+ if asked == nil {
471
+ t.Fatal("no permission request reached the client")
472
+ }
473
+ tc := asked["toolCall"].(map[string]any)
474
+ if tc["title"] != "Deploy where?" || len(asked["options"].([]any)) != 2 {
475
+ t.Errorf("request = %v", asked)
476
+ }
477
+ got := c.untilUpdate("the extension's notification", func(u map[string]any) bool {
478
+ return u["sessionUpdate"] == "agent_message_chunk" && strings.Contains(chunksText([]map[string]any{u}, "agent_message_chunk"), "chose production")
479
+ })
480
+ if m, _ := got["_meta"].(map[string]any); m["piAcp"].(map[string]any)["notify"].(map[string]any)["level"] != "info" {
481
+ t.Errorf("notify meta = %v", got["_meta"])
482
+ }
483
+ }
484
+
485
+ func TestE2ENoModelsIsAuthRequired(t *testing.T) {
486
+ pig := e2ePig(t)
487
+ work := e2eHome(t, nil) // no provider configured
488
+ c := startPigACP(t, "--pig", pig)
489
+ c.initialize()
490
+ r := c.call("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})
491
+ e, _ := r["error"].(map[string]any)
492
+ if e == nil || e["code"] != float64(-32000) {
493
+ t.Fatalf("response = %v", r)
494
+ }
495
+ methods, _ := e["data"].(map[string]any)["authMethods"].([]any)
496
+ if len(methods) != 1 || methods[0].(map[string]any)["id"] != "pi_terminal_login" {
497
+ t.Errorf("authMethods = %v", methods)
498
+ }
499
+ }
500
+
501
+ func TestE2EMissingPigIsAnInternalError(t *testing.T) {
502
+ work := e2eHome(t, nil)
503
+ c := startPigACP(t, "--pig", "pig-does-not-exist-12345")
504
+ c.initialize()
505
+ r := c.call("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})
506
+ e, _ := r["error"].(map[string]any)
507
+ if e == nil || e["code"] != float64(-32603) || !strings.Contains(strings.ToLower(e["message"].(string)), "executable not found") {
508
+ t.Fatalf("response = %v", r)
509
+ }
510
+ }
511
+
512
+ func TestE2EMcpServersAreAcceptedButNotStarted(t *testing.T) {
513
+ pig := e2ePig(t)
514
+ llm := startFakeLLM(t)
515
+ work := e2eHome(t, llm)
516
+ c := startPigACP(t, "--pig", pig)
517
+ c.initialize()
518
+ res := c.result("session/new", map[string]any{"cwd": work, "mcpServers": []any{
519
+ map[string]any{"name": "fs", "command": "/nonexistent/mcp-server", "args": []any{}, "env": []any{}}}})
520
+ if res["sessionId"] == "" {
521
+ t.Errorf("session/new = %v", res)
522
+ }
523
+ }
524
+
525
+ func TestE2EClosingStdinStopsThePigChild(t *testing.T) {
526
+ pig := e2ePig(t)
527
+ llm := startFakeLLM(t)
528
+ work := e2eHome(t, llm)
529
+ c := startPigACP(t, "--pig", pig)
530
+ c.initialize()
531
+ c.result("session/new", map[string]any{"cwd": work, "mcpServers": []any{}})
532
+ c.in.Close()
533
+ select {
534
+ case code := <-c.exit:
535
+ if code != 0 {
536
+ t.Errorf("exit %d", code)
537
+ }
538
+ case <-time.After(30 * time.Second):
539
+ t.Fatal("pig-acp did not exit after stdin closed")
540
+ }
541
+ }
@@ -0,0 +1,6 @@
1
+ module github.com/MichaelKinsy/pigpen/acp/cmd/pig-acp
2
+
3
+ go 1.26
4
+
5
+ // The companion executable talks to `pig --mode rpc` over a pipe and to the editor over
6
+ // stdio. It imports no PiG package (public or internal) and no third-party module.