@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.
- package/CREDITS.md +23 -0
- package/LICENSE +22 -0
- package/README.md +110 -0
- package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
- package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
- package/extensions/acp/cmd/pig-acp/go.mod +6 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/main.go +177 -0
- package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
- package/extensions/acp/extension.go +63 -0
- package/extensions/acp/extension_test.go +111 -0
- package/extensions/acp/fakehost_test.go +548 -0
- package/extensions/acp/go.mod +6 -0
- package/extensions/acp/go.sum +2 -0
- package/package.json +41 -0
- package/port/PORT.md +147 -0
- package/port/interop/package-lock.json +89 -0
- package/port/interop/package.json +13 -0
- package/port/interop/run.mjs +229 -0
- package/port/mutate.mjs +110 -0
- package/port/mutation-results.txt +107 -0
- package/port/mutations.json +748 -0
- package/port/oracle/pi-acp/LICENSE +21 -0
- package/port/oracle/pi-acp/README.md +209 -0
- package/port/oracle/pi-acp/package.json +65 -0
- package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
- package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
- package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
- package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
- package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
- package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
- package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
- package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
- package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
- package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
- package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
- package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
- package/port/oracle/pi-acp/src/index.ts +82 -0
- package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
- package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
- package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
- package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
- package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
- package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
- package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
- package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
- package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
- package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
- package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
- package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
- package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
- package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
- package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
- package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
- package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
- package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
- package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
- package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
- package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
- package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
- package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
- package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
- package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
- package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
- package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
- package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
- package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
- package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
- package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
- package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
- package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
- package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
- package/port/schema/LICENSE +191 -0
- package/port/schema/schema.json +8015 -0
- package/port/upstream-tests.json +201 -0
- 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
|
+
}
|