@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,355 @@
|
|
|
1
|
+
package pirpc
|
|
2
|
+
|
|
3
|
+
// Twins of test/unit/pi-rpc-request-timeout.test.ts, pi-rpc-session-path.test.ts,
|
|
4
|
+
// thinking-level-rpc.test.ts and pi-command.test.ts.
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"bufio"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"errors"
|
|
10
|
+
"fmt"
|
|
11
|
+
"io"
|
|
12
|
+
"os"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"reflect"
|
|
15
|
+
"runtime"
|
|
16
|
+
"strings"
|
|
17
|
+
"sync"
|
|
18
|
+
"testing"
|
|
19
|
+
"time"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
// TestMain doubles as the fake pi executable of pi-rpc-session-path.test.ts (fake-pi.cjs): the
|
|
23
|
+
// test binary re-executed. It appends "started" to the --session file, answers every command
|
|
24
|
+
// with that path as sessionFile, and exits shortly after the first one.
|
|
25
|
+
func TestMain(m *testing.M) {
|
|
26
|
+
if os.Getenv("PIRPC_FAKE_PI") == "1" {
|
|
27
|
+
fakePi()
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
root, _ := os.MkdirTemp("", "pirpc-test-*")
|
|
31
|
+
for _, k := range []string{"HOME", "PIG_HOME", "PIG_CODING_AGENT_DIR", "PI_CODING_AGENT_DIR"} {
|
|
32
|
+
os.Setenv(k, filepath.Join(root, k))
|
|
33
|
+
}
|
|
34
|
+
code := m.Run()
|
|
35
|
+
os.RemoveAll(root)
|
|
36
|
+
os.Exit(code)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func fakePi() {
|
|
40
|
+
var sessionPath string
|
|
41
|
+
for i, a := range os.Args {
|
|
42
|
+
if a == "--session" && i+1 < len(os.Args) {
|
|
43
|
+
sessionPath = os.Args[i+1]
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if sessionPath != "" {
|
|
47
|
+
f, err := os.OpenFile(sessionPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
48
|
+
if err != nil {
|
|
49
|
+
fmt.Fprintln(os.Stderr, err)
|
|
50
|
+
os.Exit(2)
|
|
51
|
+
}
|
|
52
|
+
f.WriteString("started\n")
|
|
53
|
+
f.Close()
|
|
54
|
+
}
|
|
55
|
+
sc := bufio.NewScanner(os.Stdin)
|
|
56
|
+
for sc.Scan() {
|
|
57
|
+
var c map[string]any
|
|
58
|
+
_ = json.Unmarshal(sc.Bytes(), &c)
|
|
59
|
+
out, _ := json.Marshal(map[string]any{"type": "response", "id": c["id"], "command": c["type"], "success": true, "data": map[string]any{"sessionFile": sessionPath}})
|
|
60
|
+
fmt.Println(string(out))
|
|
61
|
+
time.Sleep(20 * time.Millisecond)
|
|
62
|
+
os.Exit(0)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// fakeChild is makeFakeChild(): pipes standing in for the child's stdio.
|
|
67
|
+
type fakeChild struct {
|
|
68
|
+
stdoutW *io.PipeWriter
|
|
69
|
+
mu sync.Mutex
|
|
70
|
+
written []string
|
|
71
|
+
proc *Process
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type recordingWriter struct{ c *fakeChild }
|
|
75
|
+
|
|
76
|
+
func (w recordingWriter) Write(p []byte) (int, error) {
|
|
77
|
+
w.c.mu.Lock()
|
|
78
|
+
w.c.written = append(w.c.written, string(p))
|
|
79
|
+
w.c.mu.Unlock()
|
|
80
|
+
return len(p), nil
|
|
81
|
+
}
|
|
82
|
+
func (w recordingWriter) Close() error { return nil }
|
|
83
|
+
|
|
84
|
+
func newFakeChild() *fakeChild {
|
|
85
|
+
r, w := io.Pipe()
|
|
86
|
+
c := &fakeChild{stdoutW: w}
|
|
87
|
+
c.proc = NewProcess(recordingWriter{c}, r, func() {})
|
|
88
|
+
return c
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func (c *fakeChild) sent(t *testing.T, n int) map[string]any {
|
|
92
|
+
t.Helper()
|
|
93
|
+
deadline := time.Now().Add(5 * time.Second)
|
|
94
|
+
for time.Now().Before(deadline) {
|
|
95
|
+
c.mu.Lock()
|
|
96
|
+
if len(c.written) > n {
|
|
97
|
+
line := c.written[n]
|
|
98
|
+
c.mu.Unlock()
|
|
99
|
+
var m map[string]any
|
|
100
|
+
if err := json.Unmarshal([]byte(line), &m); err != nil {
|
|
101
|
+
t.Fatalf("written %q: %v", line, err)
|
|
102
|
+
}
|
|
103
|
+
return m
|
|
104
|
+
}
|
|
105
|
+
c.mu.Unlock()
|
|
106
|
+
time.Sleep(2 * time.Millisecond)
|
|
107
|
+
}
|
|
108
|
+
t.Fatal("no request was written")
|
|
109
|
+
return nil
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func (c *fakeChild) send(msg any) {
|
|
113
|
+
b, _ := json.Marshal(msg)
|
|
114
|
+
done := make(chan struct{})
|
|
115
|
+
go func() { c.stdoutW.Write(append(b, '\n')); close(done) }()
|
|
116
|
+
select {
|
|
117
|
+
case <-done:
|
|
118
|
+
case <-time.After(3 * time.Second):
|
|
119
|
+
c.stdoutW.CloseWithError(errors.New("nobody reads the child's stdout"))
|
|
120
|
+
panic("the process did not read its child's stdout")
|
|
121
|
+
}
|
|
122
|
+
time.Sleep(20 * time.Millisecond) // the JS test waits two setImmediate turns
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func TestRequestTimeout(t *testing.T) {
|
|
126
|
+
tw(t, "unit/pi-rpc-request-timeout", "PiRpcProcess: auxiliary context usage has a one-second timeout", func(t *testing.T) {
|
|
127
|
+
if SessionStatsTimeoutMs != 1000 {
|
|
128
|
+
t.Errorf("SessionStatsTimeoutMs = %d", SessionStatsTimeoutMs)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
tw(t, "unit/pi-rpc-request-timeout", "PiRpcProcess: getSessionStats without a timeout accepts a response after one second", func(t *testing.T) {
|
|
133
|
+
c := newFakeChild()
|
|
134
|
+
type res struct {
|
|
135
|
+
s map[string]any
|
|
136
|
+
err error
|
|
137
|
+
}
|
|
138
|
+
done := make(chan res, 1)
|
|
139
|
+
go func() { s, err := c.proc.GetSessionStats(0); done <- res{s, err} }()
|
|
140
|
+
sent := c.sent(t, 0)
|
|
141
|
+
time.Sleep(time.Duration(SessionStatsTimeoutMs+50) * time.Millisecond)
|
|
142
|
+
if c.proc.PendingCount() != 1 {
|
|
143
|
+
t.Fatalf("pending = %d", c.proc.PendingCount())
|
|
144
|
+
}
|
|
145
|
+
c.send(map[string]any{"type": "response", "id": sent["id"], "command": "get_session_stats", "success": true, "data": map[string]any{"totalMessages": 42}})
|
|
146
|
+
r := <-done
|
|
147
|
+
if r.err != nil || !reflect.DeepEqual(r.s, map[string]any{"totalMessages": float64(42)}) {
|
|
148
|
+
t.Fatalf("got %v, %v", r.s, r.err)
|
|
149
|
+
}
|
|
150
|
+
if c.proc.PendingCount() != 0 {
|
|
151
|
+
t.Errorf("pending = %d", c.proc.PendingCount())
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
tw(t, "unit/pi-rpc-request-timeout", "PiRpcProcess: request timeout rejects, clears pending, and swallows the late response", func(t *testing.T) {
|
|
156
|
+
c := newFakeChild()
|
|
157
|
+
var mu sync.Mutex
|
|
158
|
+
var events []map[string]any
|
|
159
|
+
c.proc.OnEvent(func(e map[string]any) { mu.Lock(); events = append(events, e); mu.Unlock() })
|
|
160
|
+
_, err := c.proc.GetSessionStats(5)
|
|
161
|
+
if err == nil || !strings.Contains(err.Error(), "pi get_session_stats timed out after 5ms") {
|
|
162
|
+
t.Fatalf("err = %v", err)
|
|
163
|
+
}
|
|
164
|
+
if c.proc.PendingCount() != 0 {
|
|
165
|
+
t.Errorf("pending = %d", c.proc.PendingCount())
|
|
166
|
+
}
|
|
167
|
+
sent := c.sent(t, 0)
|
|
168
|
+
if sent["type"] != "get_session_stats" {
|
|
169
|
+
t.Errorf("sent = %v", sent)
|
|
170
|
+
}
|
|
171
|
+
c.send(map[string]any{"type": "response", "id": sent["id"], "command": "get_session_stats", "success": true, "data": map[string]any{"contextUsage": map[string]any{"tokens": 1, "contextWindow": 2}}})
|
|
172
|
+
mu.Lock()
|
|
173
|
+
defer mu.Unlock()
|
|
174
|
+
if len(events) != 0 || c.proc.PendingCount() != 0 {
|
|
175
|
+
t.Errorf("events=%v pending=%d", events, c.proc.PendingCount())
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
tw(t, "unit/pi-rpc-request-timeout", "PiRpcProcess: response before the timeout resolves and clears pending", func(t *testing.T) {
|
|
180
|
+
c := newFakeChild()
|
|
181
|
+
var mu sync.Mutex
|
|
182
|
+
var events []map[string]any
|
|
183
|
+
c.proc.OnEvent(func(e map[string]any) { mu.Lock(); events = append(events, e); mu.Unlock() })
|
|
184
|
+
type res struct {
|
|
185
|
+
s map[string]any
|
|
186
|
+
err error
|
|
187
|
+
}
|
|
188
|
+
done := make(chan res, 1)
|
|
189
|
+
go func() { s, err := c.proc.GetSessionStats(5000); done <- res{s, err} }()
|
|
190
|
+
sent := c.sent(t, 0)
|
|
191
|
+
c.send(map[string]any{"type": "response", "id": sent["id"], "command": "get_session_stats", "success": true, "data": map[string]any{"contextUsage": map[string]any{"tokens": 10, "contextWindow": 100}}})
|
|
192
|
+
r := <-done
|
|
193
|
+
want := map[string]any{"contextUsage": map[string]any{"tokens": float64(10), "contextWindow": float64(100)}}
|
|
194
|
+
if r.err != nil || !reflect.DeepEqual(r.s, want) {
|
|
195
|
+
t.Fatalf("got %v, %v", r.s, r.err)
|
|
196
|
+
}
|
|
197
|
+
mu.Lock()
|
|
198
|
+
defer mu.Unlock()
|
|
199
|
+
if c.proc.PendingCount() != 0 || len(events) != 0 {
|
|
200
|
+
t.Errorf("pending=%d events=%v", c.proc.PendingCount(), events)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
tw(t, "unit/pi-rpc-request-timeout", "PiRpcProcess: responses with unknown ids are dropped while real events are delivered", func(t *testing.T) {
|
|
205
|
+
c := newFakeChild()
|
|
206
|
+
var mu sync.Mutex
|
|
207
|
+
var events []map[string]any
|
|
208
|
+
c.proc.OnEvent(func(e map[string]any) { mu.Lock(); events = append(events, e); mu.Unlock() })
|
|
209
|
+
c.send(map[string]any{"type": "response", "id": "nope", "command": "get_state", "success": true, "data": map[string]any{}})
|
|
210
|
+
c.send(map[string]any{"type": "response", "command": "get_state", "success": true, "data": map[string]any{}})
|
|
211
|
+
c.send(map[string]any{"type": "agent_start"})
|
|
212
|
+
mu.Lock()
|
|
213
|
+
defer mu.Unlock()
|
|
214
|
+
if !reflect.DeepEqual(events, []map[string]any{{"type": "agent_start"}}) {
|
|
215
|
+
t.Errorf("events = %v", events)
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func TestSessionPath(t *testing.T) {
|
|
221
|
+
launcher := func(t *testing.T, root, name string) string {
|
|
222
|
+
t.Helper()
|
|
223
|
+
if runtime.GOOS == "windows" {
|
|
224
|
+
t.Skip("the fake launcher is a POSIX shell script")
|
|
225
|
+
}
|
|
226
|
+
exe, err := os.Executable()
|
|
227
|
+
if err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
// The launcher is a shell script that re-executes the test binary as the fake pi.
|
|
231
|
+
p := filepath.Join(root, name)
|
|
232
|
+
script := fmt.Sprintf("#!/bin/sh\nPIRPC_FAKE_PI=1 exec %q \"$@\"\n", exe)
|
|
233
|
+
if err := os.WriteFile(p, []byte(script), 0o755); err != nil {
|
|
234
|
+
t.Fatal(err)
|
|
235
|
+
}
|
|
236
|
+
return p
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
tw(t, "unit/pi-rpc-session-path", "PiRpcProcess passes distinct session paths intact to the pi launcher", func(t *testing.T) {
|
|
240
|
+
root := filepath.Join(t.TempDir(), "pi-acp launcher ")
|
|
241
|
+
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
242
|
+
t.Fatal(err)
|
|
243
|
+
}
|
|
244
|
+
t.Setenv("PI_ACP_PATH_TEST", "expanded")
|
|
245
|
+
sessionDir := filepath.Join(root, "project & (a) ^ 100% !PI_ACP_PATH_TEST! %PI_ACP_PATH_TEST%")
|
|
246
|
+
if err := os.MkdirAll(sessionDir, 0o755); err != nil {
|
|
247
|
+
t.Fatal(err)
|
|
248
|
+
}
|
|
249
|
+
l := launcher(t, root, "pi")
|
|
250
|
+
for _, name := range []string{"first", "second"} {
|
|
251
|
+
path := filepath.Join(sessionDir, "pi-"+name+".jsonl")
|
|
252
|
+
p, err := Spawn(root, l, path)
|
|
253
|
+
if err != nil {
|
|
254
|
+
t.Fatal(err)
|
|
255
|
+
}
|
|
256
|
+
p.Dispose()
|
|
257
|
+
b, err := os.ReadFile(path)
|
|
258
|
+
if err != nil || string(b) != "started\n" {
|
|
259
|
+
t.Errorf("%s: %q, %v", name, b, err)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if _, err := os.Stat(filepath.Join(root, "project")); !os.IsNotExist(err) {
|
|
263
|
+
t.Error("the session path was split at a shell metacharacter")
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
tskip(t, "unit/pi-rpc-session-path", "PiRpcProcess keeps a simulated Windows cmd session path intact",
|
|
268
|
+
"gap: the original fakes process.platform = 'win32' to force its cross-spawn path. Go cannot fake runtime.GOOS; a real .cmd launcher needs Windows. ShouldUseShell is covered with an explicit goos argument, and Windows execution is checked with GOOS=windows go vet only (see PORT.md).")
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func TestSpawnError(t *testing.T) {
|
|
272
|
+
t.Run("PiAcpAgent: newSession returns a helpful Internal error when pi is not installed (spawn layer)", func(t *testing.T) {
|
|
273
|
+
_, err := Spawn(t.TempDir(), "pi-does-not-exist-12345", "")
|
|
274
|
+
var se *SpawnError
|
|
275
|
+
if !errors.As(err, &se) || se.Code != "ENOENT" || !strings.Contains(strings.ToLower(se.Message), "executable not found") {
|
|
276
|
+
t.Fatalf("err = %v", err)
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
func thinkingRPC(response Response, err error) *Process {
|
|
282
|
+
p := &Process{}
|
|
283
|
+
p.requestFn = func(cmd map[string]any, _ time.Duration) (Response, error) {
|
|
284
|
+
if !reflect.DeepEqual(cmd, map[string]any{"type": "get_available_thinking_levels"}) {
|
|
285
|
+
panic(fmt.Sprintf("unexpected command %v", cmd))
|
|
286
|
+
}
|
|
287
|
+
return response, err
|
|
288
|
+
}
|
|
289
|
+
return p
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func TestThinkingLevelRPC(t *testing.T) {
|
|
293
|
+
for _, levels := range [][]string{{"low", "high", "max"}, {"off"}, {"max", "low"}, {"ordinary", "mean"}, {" mean ", " "}} {
|
|
294
|
+
tw(t, "unit/thinking-level-rpc", "thinking RPC preserves returned levels ${levels}", func(t *testing.T) {
|
|
295
|
+
t.Run(fmt.Sprintf("thinking RPC preserves returned levels %v", levels), func(t *testing.T) {
|
|
296
|
+
var raw []any
|
|
297
|
+
for _, l := range levels {
|
|
298
|
+
raw = append(raw, l)
|
|
299
|
+
}
|
|
300
|
+
got, err := thinkingRPC(Response{Success: true, Data: map[string]any{"levels": raw}}, nil).GetAvailableThinkingLevels()
|
|
301
|
+
if err != nil || !reflect.DeepEqual(got, levels) {
|
|
302
|
+
t.Fatalf("got %q, %v", got, err)
|
|
303
|
+
}
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
for _, data := range []any{nil, nil, map[string]any{}, map[string]any{"levels": []any{}}, map[string]any{"levels": "high"},
|
|
308
|
+
map[string]any{"levels": []any{"high", ""}}, map[string]any{"levels": []any{"high", nil}}, map[string]any{"levels": []any{1}}} {
|
|
309
|
+
b, _ := json.Marshal(data)
|
|
310
|
+
tw(t, "unit/thinking-level-rpc", "thinking RPC rejects malformed payload ${JSON.stringify(data)}", func(t *testing.T) {
|
|
311
|
+
t.Run("thinking RPC rejects malformed payload "+string(b), func(t *testing.T) {
|
|
312
|
+
_, err := thinkingRPC(Response{Success: true, Data: data}, nil).GetAvailableThinkingLevels()
|
|
313
|
+
if err == nil || !strings.Contains(err.Error(), "invalid levels") {
|
|
314
|
+
t.Fatalf("err = %v", err)
|
|
315
|
+
}
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
tw(t, "unit/thinking-level-rpc", "thinking RPC propagates unsuccessful responses and transport rejection", func(t *testing.T) {
|
|
320
|
+
_, err := thinkingRPC(Response{Success: false, Error: "unsupported command"}, nil).GetAvailableThinkingLevels()
|
|
321
|
+
if err == nil || !strings.Contains(err.Error(), "unsupported command") {
|
|
322
|
+
t.Errorf("err = %v", err)
|
|
323
|
+
}
|
|
324
|
+
_, err = thinkingRPC(Response{}, errors.New("connection closed")).GetAvailableThinkingLevels()
|
|
325
|
+
if err == nil || !strings.Contains(err.Error(), "connection closed") {
|
|
326
|
+
t.Errorf("err = %v", err)
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func TestPiCommand(t *testing.T) {
|
|
332
|
+
tw(t, "unit/pi-command", "defaultPiCommand: uses pi.cmd on Windows and pi elsewhere", func(t *testing.T) {
|
|
333
|
+
// The port's launcher is pig; the Windows launcher of an npm-style install is pig.cmd.
|
|
334
|
+
if DefaultCommand("windows") != "pig.cmd" || DefaultCommand("darwin") != "pig" {
|
|
335
|
+
t.Errorf("windows=%q darwin=%q", DefaultCommand("windows"), DefaultCommand("darwin"))
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
tw(t, "unit/pi-command", "shouldUseShellForPiCommand: enables shell for Windows cmd launchers only", func(t *testing.T) {
|
|
339
|
+
for cmd, want := range map[string]bool{"pig.cmd": true, `C:\Users\me\AppData\Roaming\npm\pig.CMD`: true, "pig.bat": true, "pig": false, `C:\tools\pig.exe`: false} {
|
|
340
|
+
if got := ShouldUseShell("windows", cmd); got != want {
|
|
341
|
+
t.Errorf("%q: got %v, want %v", cmd, got, want)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
tw(t, "unit/pi-command", "shouldUseShellForPiCommand: keeps shell disabled on non-Windows", func(t *testing.T) {
|
|
346
|
+
if ShouldUseShell("darwin", "pig.cmd") || ShouldUseShell("darwin", "pig") {
|
|
347
|
+
t.Error("shell enabled on darwin")
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
t.Run("getPiCommand: an override wins", func(t *testing.T) {
|
|
351
|
+
if Command("/opt/pig") != "/opt/pig" {
|
|
352
|
+
t.Error("override ignored")
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package pirpc
|
|
2
|
+
|
|
3
|
+
// Twin helpers for a port whose original has tests. Every upstream test case gets one
|
|
4
|
+
// tw(...) call carrying the upstream title unchanged, or one tskip(...) with the reason it
|
|
5
|
+
// cannot be ported. `pigeq twins check` reads these calls and fails on a missing title, an
|
|
6
|
+
// unknown one (a misspelling) or one reason shared by many skips.
|
|
7
|
+
//
|
|
8
|
+
// pigeq twins list --tests <upstream>/test > port/upstream-tests.json
|
|
9
|
+
// pigeq twins check --ledger port/upstream-tests.json --go extensions/<name> [--files a,b]
|
|
10
|
+
//
|
|
11
|
+
// Copy this file into the port as twin_test.go and replace pirpc with the package name (in
|
|
12
|
+
// an external test package use `pirpc_test`). A parametrized upstream title (it contains
|
|
13
|
+
// `${`) is twinned once, with the template text unchanged.
|
|
14
|
+
|
|
15
|
+
import "testing"
|
|
16
|
+
|
|
17
|
+
// tw runs one exact twin: file is the upstream test file (its name without .test.mjs), title
|
|
18
|
+
// the upstream test title, unchanged.
|
|
19
|
+
func tw(t *testing.T, file, title string, fn func(t *testing.T)) {
|
|
20
|
+
t.Helper()
|
|
21
|
+
t.Run(title, fn)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// tskip records a named gap: the upstream case has no Go twin, with its own reason.
|
|
25
|
+
func tskip(t *testing.T, file, title, reason string) {
|
|
26
|
+
t.Helper()
|
|
27
|
+
t.Run(title, func(t *testing.T) { t.Skip(reason) })
|
|
28
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Command pig-acp is the Agent Client Protocol entrypoint for PiG: an editor starts it,
|
|
2
|
+
// speaks ACP on its stdin and stdout, and pig-acp drives `pig --mode rpc`. It is a Go port of
|
|
3
|
+
// pi-acp by Sergii Kozak (MIT); see the Package's CREDITS.md.
|
|
4
|
+
//
|
|
5
|
+
// Only protocol messages go to stdout. Diagnostics go to stderr.
|
|
6
|
+
package main
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"context"
|
|
10
|
+
"errors"
|
|
11
|
+
"flag"
|
|
12
|
+
"fmt"
|
|
13
|
+
"io"
|
|
14
|
+
"os"
|
|
15
|
+
"os/exec"
|
|
16
|
+
"os/signal"
|
|
17
|
+
"strings"
|
|
18
|
+
"syscall"
|
|
19
|
+
|
|
20
|
+
"github.com/MichaelKinsy/pigpen/acp/cmd/pig-acp/internal/acp"
|
|
21
|
+
"github.com/MichaelKinsy/pigpen/acp/cmd/pig-acp/internal/pirpc"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
// version is set at build time with -ldflags "-X main.version=...".
|
|
25
|
+
var version = "0.1.0"
|
|
26
|
+
|
|
27
|
+
type multiFlag []string
|
|
28
|
+
|
|
29
|
+
func (m *multiFlag) String() string { return strings.Join(*m, " ") }
|
|
30
|
+
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
|
|
31
|
+
|
|
32
|
+
const usage = `pig-acp: Agent Client Protocol (ACP) adapter for PiG
|
|
33
|
+
|
|
34
|
+
An editor (Zed, JetBrains and other ACP clients) starts pig-acp and speaks ACP on its
|
|
35
|
+
stdin and stdout. pig-acp starts 'pig --mode rpc' for every session.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
pig-acp [--pig <command>] [--piglet <name-or-file>] [--pig-arg <arg>]...
|
|
39
|
+
pig-acp --terminal-login [--pig <command>]
|
|
40
|
+
pig-acp --version | --help
|
|
41
|
+
|
|
42
|
+
Flags:
|
|
43
|
+
--pig <command> the pig executable or a Piglet Binary to drive (default: pig)
|
|
44
|
+
--piglet <value> start pig with --piglet <value> (a Piglet name or a piglet.yaml)
|
|
45
|
+
--pig-arg <arg> an extra argument for pig; repeatable
|
|
46
|
+
--terminal-login start pig interactively, for signing in or configuring keys
|
|
47
|
+
--version print the version and the pinned ACP protocol version
|
|
48
|
+
--help print this text
|
|
49
|
+
|
|
50
|
+
Environment:
|
|
51
|
+
PIG_ACP_PIG_COMMAND the pig executable when --pig is not given
|
|
52
|
+
(PI_ACP_PI_COMMAND is accepted for pi-acp users)
|
|
53
|
+
PIG_ACP_ENABLE_EMBEDDED_CONTEXT "true" advertises embedded context support in prompts
|
|
54
|
+
(PI_ACP_ENABLE_EMBEDDED_CONTEXT is accepted)
|
|
55
|
+
PIG_HOME, PIG_CODING_AGENT_DIR where pig keeps its state; the session map is <PIG_HOME>/pig-acp/
|
|
56
|
+
`
|
|
57
|
+
|
|
58
|
+
func run(args []string, in io.Reader, out, errw io.Writer) int {
|
|
59
|
+
return runContext(context.Background(), args, in, out, errw)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func runContext(ctx context.Context, args []string, in io.Reader, out, errw io.Writer) int {
|
|
63
|
+
fs := flag.NewFlagSet("pig-acp", flag.ContinueOnError)
|
|
64
|
+
fs.SetOutput(io.Discard)
|
|
65
|
+
var pigCmd, piglet string
|
|
66
|
+
var pigArgs multiFlag
|
|
67
|
+
var showVersion, showHelp, terminalLogin bool
|
|
68
|
+
fs.StringVar(&pigCmd, "pig", "", "")
|
|
69
|
+
fs.StringVar(&piglet, "piglet", "", "")
|
|
70
|
+
fs.Var(&pigArgs, "pig-arg", "")
|
|
71
|
+
fs.BoolVar(&showVersion, "version", false, "")
|
|
72
|
+
fs.BoolVar(&showHelp, "help", false, "")
|
|
73
|
+
fs.BoolVar(&terminalLogin, "terminal-login", false, "")
|
|
74
|
+
if err := fs.Parse(args); err != nil {
|
|
75
|
+
if errors.Is(err, flag.ErrHelp) {
|
|
76
|
+
fmt.Fprint(out, usage)
|
|
77
|
+
return 0
|
|
78
|
+
}
|
|
79
|
+
msg := strings.Replace(err.Error(), "not defined: -", "not defined: --", 1)
|
|
80
|
+
fmt.Fprintf(errw, "pig-acp: %s\n\n%s", msg, usage)
|
|
81
|
+
return 2
|
|
82
|
+
}
|
|
83
|
+
if fs.NArg() > 0 {
|
|
84
|
+
fmt.Fprintf(errw, "pig-acp: unexpected argument %q\n\n%s", fs.Arg(0), usage)
|
|
85
|
+
return 2
|
|
86
|
+
}
|
|
87
|
+
switch {
|
|
88
|
+
case showHelp:
|
|
89
|
+
fmt.Fprint(out, usage)
|
|
90
|
+
return 0
|
|
91
|
+
case showVersion:
|
|
92
|
+
fmt.Fprintf(out, "pig-acp %s (ACP protocol version %d)\n", version, acp.ProtocolVersion)
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if pigCmd == "" {
|
|
97
|
+
for _, name := range []string{"PIG_ACP_PIG_COMMAND", "PI_ACP_PI_COMMAND"} {
|
|
98
|
+
if v := os.Getenv(name); v != "" {
|
|
99
|
+
pigCmd = v
|
|
100
|
+
break
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
extra := append([]string{}, pigArgs...)
|
|
105
|
+
if piglet != "" {
|
|
106
|
+
extra = append([]string{"--piglet", piglet}, extra...)
|
|
107
|
+
}
|
|
108
|
+
// A terminal login started by the editor must start the same agent the sessions do: the same pig,
|
|
109
|
+
// Piglet and extra arguments.
|
|
110
|
+
login := []string{}
|
|
111
|
+
if pigCmd != "" {
|
|
112
|
+
login = append(login, "--pig", pigCmd)
|
|
113
|
+
}
|
|
114
|
+
if piglet != "" {
|
|
115
|
+
login = append(login, "--piglet", piglet)
|
|
116
|
+
}
|
|
117
|
+
for _, a := range pigArgs {
|
|
118
|
+
login = append(login, "--pig-arg", a)
|
|
119
|
+
}
|
|
120
|
+
acp.TerminalLoginArgs = append(login, "--terminal-login")
|
|
121
|
+
|
|
122
|
+
if terminalLogin {
|
|
123
|
+
return terminalLoginRun(pigCmd, extra, in, out, errw)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
acp.Version = version
|
|
127
|
+
err := acp.Serve(in, out, acp.ServeOptions{
|
|
128
|
+
Done: ctx.Done(),
|
|
129
|
+
NewAgent: func(conn acp.Conn) *acp.Agent {
|
|
130
|
+
a := acp.NewAgent(conn)
|
|
131
|
+
a.Configure(pigCmd, extra)
|
|
132
|
+
return a
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
if err != nil {
|
|
136
|
+
fmt.Fprintf(errw, "pig-acp: %v\n", err)
|
|
137
|
+
return 1
|
|
138
|
+
}
|
|
139
|
+
return 0
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// terminalLoginRun starts pig interactively (the ACP terminal auth entrypoint).
|
|
143
|
+
func terminalLoginRun(pigCmd string, extra []string, in io.Reader, out, errw io.Writer) int {
|
|
144
|
+
cmd := pirpc.Command(pigCmd)
|
|
145
|
+
c := exec.Command(cmd, extra...)
|
|
146
|
+
c.Stdin, c.Stdout, c.Stderr = in, out, errw
|
|
147
|
+
c.Env = os.Environ()
|
|
148
|
+
if err := c.Run(); err != nil {
|
|
149
|
+
var exitErr *exec.ExitError
|
|
150
|
+
if errors.As(err, &exitErr) {
|
|
151
|
+
if code := exitErr.ExitCode(); code >= 0 {
|
|
152
|
+
return code
|
|
153
|
+
}
|
|
154
|
+
return 1
|
|
155
|
+
}
|
|
156
|
+
if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) {
|
|
157
|
+
fmt.Fprintf(errw, "pig-acp: could not start pig (command not found: %s). Install PiG (https://github.com/MichaelKinsy/PiG) or ensure `pig` is on your PATH, or pass --pig <path>.\n", cmd)
|
|
158
|
+
return 1
|
|
159
|
+
}
|
|
160
|
+
fmt.Fprintf(errw, "pig-acp: could not start pig (%s): %v\n", cmd, err)
|
|
161
|
+
return 1
|
|
162
|
+
}
|
|
163
|
+
return 0
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// buildPigArgs is the argument list of the pig child.
|
|
167
|
+
func buildPigArgs(extra []string, sessionPath string) []string {
|
|
168
|
+
return pirpc.BuildArgs(extra, sessionPath)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
func main() {
|
|
172
|
+
// A client that closes our stdout must end the run quietly, not kill the process with SIGPIPE.
|
|
173
|
+
ignoreSIGPIPE()
|
|
174
|
+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
175
|
+
defer stop()
|
|
176
|
+
os.Exit(runContext(ctx, os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
|
177
|
+
}
|