@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,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
+ }