@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,421 @@
1
+ package acp
2
+
3
+ // Test helpers: the fakes of test/helpers/fakes.ts (FakeAgentSideConnection and
4
+ // FakePiRpcProcess) and small assertions. The test files are named after the upstream
5
+ // files they port; every twin keeps its upstream test name in a comment or subtest name.
6
+
7
+ import (
8
+ "encoding/json"
9
+ "fmt"
10
+ "reflect"
11
+ "sync"
12
+ "testing"
13
+ "time"
14
+ )
15
+
16
+ // sentUpdate is one session/update the fake connection received.
17
+ type sentUpdate struct {
18
+ SessionID string
19
+ Update Update
20
+ }
21
+
22
+ // fakeConn is FakeAgentSideConnection.
23
+ type fakeConn struct {
24
+ mu sync.Mutex
25
+ updates []sentUpdate
26
+ permissionRequests []PermissionRequest
27
+ nextPermission PermissionResponse
28
+ // sessionUpdateHook, when set, runs inside SessionUpdate before the update is recorded.
29
+ sessionUpdateHook func(u Update)
30
+ }
31
+
32
+ func newFakeConn() *fakeConn {
33
+ return &fakeConn{nextPermission: PermissionResponse{Outcome: PermissionOutcome{Outcome: "selected", OptionID: "allow"}}}
34
+ }
35
+
36
+ func (c *fakeConn) SessionUpdate(sessionID string, update Update) error {
37
+ c.mu.Lock()
38
+ hook := c.sessionUpdateHook
39
+ c.mu.Unlock()
40
+ if hook != nil {
41
+ hook(update)
42
+ }
43
+ c.mu.Lock()
44
+ c.updates = append(c.updates, sentUpdate{sessionID, update})
45
+ c.mu.Unlock()
46
+ return nil
47
+ }
48
+
49
+ func (c *fakeConn) RequestPermission(req PermissionRequest) (PermissionResponse, error) {
50
+ c.mu.Lock()
51
+ defer c.mu.Unlock()
52
+ c.permissionRequests = append(c.permissionRequests, req)
53
+ return c.nextPermission, nil
54
+ }
55
+
56
+ func (c *fakeConn) all() []sentUpdate {
57
+ c.mu.Lock()
58
+ defer c.mu.Unlock()
59
+ return append([]sentUpdate(nil), c.updates...)
60
+ }
61
+
62
+ func (c *fakeConn) kinds() []string {
63
+ var out []string
64
+ for _, u := range c.all() {
65
+ out = append(out, str(u.Update["sessionUpdate"]))
66
+ }
67
+ return out
68
+ }
69
+
70
+ func (c *fakeConn) ofKind(kind string) []sentUpdate {
71
+ var out []sentUpdate
72
+ for _, u := range c.all() {
73
+ if u.Update["sessionUpdate"] == kind {
74
+ out = append(out, u)
75
+ }
76
+ }
77
+ return out
78
+ }
79
+
80
+ func (c *fakeConn) reset() {
81
+ c.mu.Lock()
82
+ c.updates = nil
83
+ c.mu.Unlock()
84
+ }
85
+
86
+ func (c *fakeConn) permissions() []PermissionRequest {
87
+ c.mu.Lock()
88
+ defer c.mu.Unlock()
89
+ return append([]PermissionRequest(nil), c.permissionRequests...)
90
+ }
91
+
92
+ // fakeProc is FakePiRpcProcess. Tests override behavior through the func fields.
93
+ type fakeProc struct {
94
+ mu sync.Mutex
95
+ handlers []func(Event)
96
+
97
+ prompts []promptCall
98
+ extensionUIResponses []map[string]any
99
+ abortCount int
100
+ getSessionStatsCount int
101
+ disposed int
102
+ disposeHook func()
103
+
104
+ sessionStats SessionStats
105
+ sessionStatsError error
106
+ statsTimeouts []int
107
+
108
+ getStateFn func() (map[string]any, error)
109
+ getModelsFn func() (map[string]any, error)
110
+ getLevelsFn func() ([]string, error)
111
+ getMessagesFn func() (map[string]any, error)
112
+ getCommandsFn func() (map[string]any, error)
113
+ setModelFn func(provider, id string) error
114
+ setThinkingFn func(level string) error
115
+ setSteeringFn func(mode string) error
116
+ setFollowUpFn func(mode string) error
117
+ setSessionNameFn func(name string) error
118
+ compactFn func(instr string) (map[string]any, error)
119
+ setAutoCompactFn func(bool) error
120
+ exportHTMLFn func(path string) (string, error)
121
+ getSessionStatsFn func(timeoutMs int) (SessionStats, error)
122
+ promptErr error
123
+ promptHook func()
124
+ extensionUIRespErr error
125
+ }
126
+
127
+ type promptCall struct {
128
+ Message string
129
+ Images []Image
130
+ }
131
+
132
+ func newFakeProc() *fakeProc { return &fakeProc{} }
133
+
134
+ func (p *fakeProc) OnEvent(h func(Event)) func() {
135
+ p.mu.Lock()
136
+ p.handlers = append(p.handlers, h)
137
+ idx := len(p.handlers) - 1
138
+ p.mu.Unlock()
139
+ return func() {
140
+ p.mu.Lock()
141
+ defer p.mu.Unlock()
142
+ if idx < len(p.handlers) {
143
+ p.handlers[idx] = func(Event) {}
144
+ }
145
+ }
146
+ }
147
+
148
+ // emit delivers an event to every handler, synchronously, as FakePiRpcProcess.emit does.
149
+ func (p *fakeProc) emit(ev Event) {
150
+ p.mu.Lock()
151
+ hs := append([]func(Event){}, p.handlers...)
152
+ p.mu.Unlock()
153
+ for _, h := range hs {
154
+ h(ev)
155
+ }
156
+ }
157
+
158
+ func (p *fakeProc) Prompt(message string, images []Image) error {
159
+ p.mu.Lock()
160
+ if images == nil {
161
+ images = []Image{}
162
+ }
163
+ p.prompts = append(p.prompts, promptCall{message, images})
164
+ err, hook := p.promptErr, p.promptHook
165
+ p.mu.Unlock()
166
+ if hook != nil {
167
+ hook()
168
+ }
169
+ return err
170
+ }
171
+
172
+ func (p *fakeProc) promptList() []promptCall {
173
+ p.mu.Lock()
174
+ defer p.mu.Unlock()
175
+ return append([]promptCall(nil), p.prompts...)
176
+ }
177
+
178
+ func (p *fakeProc) Abort() error {
179
+ p.mu.Lock()
180
+ defer p.mu.Unlock()
181
+ p.abortCount++
182
+ return nil
183
+ }
184
+
185
+ func (p *fakeProc) aborts() int {
186
+ p.mu.Lock()
187
+ defer p.mu.Unlock()
188
+ return p.abortCount
189
+ }
190
+
191
+ func (p *fakeProc) SendExtensionUIResponse(resp map[string]any) error {
192
+ p.mu.Lock()
193
+ defer p.mu.Unlock()
194
+ p.extensionUIResponses = append(p.extensionUIResponses, resp)
195
+ return p.extensionUIRespErr
196
+ }
197
+
198
+ func (p *fakeProc) uiResponses() []map[string]any {
199
+ p.mu.Lock()
200
+ defer p.mu.Unlock()
201
+ return append([]map[string]any(nil), p.extensionUIResponses...)
202
+ }
203
+
204
+ func (p *fakeProc) GetState() (map[string]any, error) {
205
+ if p.getStateFn != nil {
206
+ return p.getStateFn()
207
+ }
208
+ return map[string]any{}, nil
209
+ }
210
+
211
+ func (p *fakeProc) GetAvailableModels() (map[string]any, error) {
212
+ if p.getModelsFn != nil {
213
+ return p.getModelsFn()
214
+ }
215
+ return map[string]any{"models": []any{map[string]any{"provider": "test", "id": "model", "name": "model"}}}, nil
216
+ }
217
+
218
+ func (p *fakeProc) GetAvailableThinkingLevels() ([]string, error) {
219
+ if p.getLevelsFn != nil {
220
+ return p.getLevelsFn()
221
+ }
222
+ return []string{"medium", "high"}, nil
223
+ }
224
+
225
+ func (p *fakeProc) GetMessages() (map[string]any, error) {
226
+ if p.getMessagesFn != nil {
227
+ return p.getMessagesFn()
228
+ }
229
+ return map[string]any{"messages": []any{}}, nil
230
+ }
231
+
232
+ func (p *fakeProc) GetCommands() (map[string]any, error) {
233
+ if p.getCommandsFn != nil {
234
+ return p.getCommandsFn()
235
+ }
236
+ return nil, fmt.Errorf("pi get_commands failed: unsupported")
237
+ }
238
+
239
+ func (p *fakeProc) GetSessionStats(timeoutMs int) (SessionStats, error) {
240
+ p.mu.Lock()
241
+ p.getSessionStatsCount++
242
+ p.statsTimeouts = append(p.statsTimeouts, timeoutMs)
243
+ fn, stats, err := p.getSessionStatsFn, p.sessionStats, p.sessionStatsError
244
+ p.mu.Unlock()
245
+ if fn != nil {
246
+ return fn(timeoutMs)
247
+ }
248
+ if err != nil {
249
+ return nil, err
250
+ }
251
+ if stats == nil {
252
+ stats = SessionStats{}
253
+ }
254
+ return stats, nil
255
+ }
256
+
257
+ func (p *fakeProc) statsCount() int {
258
+ p.mu.Lock()
259
+ defer p.mu.Unlock()
260
+ return p.getSessionStatsCount
261
+ }
262
+
263
+ func (p *fakeProc) SetModel(provider, id string) error {
264
+ if p.setModelFn != nil {
265
+ return p.setModelFn(provider, id)
266
+ }
267
+ return nil
268
+ }
269
+
270
+ func (p *fakeProc) SetThinkingLevel(level string) error {
271
+ if p.setThinkingFn != nil {
272
+ return p.setThinkingFn(level)
273
+ }
274
+ return nil
275
+ }
276
+
277
+ func (p *fakeProc) SetSteeringMode(mode string) error {
278
+ if p.setSteeringFn != nil {
279
+ return p.setSteeringFn(mode)
280
+ }
281
+ return nil
282
+ }
283
+
284
+ func (p *fakeProc) SetFollowUpMode(mode string) error {
285
+ if p.setFollowUpFn != nil {
286
+ return p.setFollowUpFn(mode)
287
+ }
288
+ return nil
289
+ }
290
+
291
+ func (p *fakeProc) SetSessionName(name string) error {
292
+ if p.setSessionNameFn != nil {
293
+ return p.setSessionNameFn(name)
294
+ }
295
+ return nil
296
+ }
297
+
298
+ func (p *fakeProc) Compact(instr string) (map[string]any, error) {
299
+ if p.compactFn != nil {
300
+ return p.compactFn(instr)
301
+ }
302
+ return map[string]any{}, nil
303
+ }
304
+
305
+ func (p *fakeProc) SetAutoCompaction(enabled bool) error {
306
+ if p.setAutoCompactFn != nil {
307
+ return p.setAutoCompactFn(enabled)
308
+ }
309
+ return nil
310
+ }
311
+
312
+ func (p *fakeProc) ExportHTML(path string) (string, error) {
313
+ if p.exportHTMLFn != nil {
314
+ return p.exportHTMLFn(path)
315
+ }
316
+ return path, nil
317
+ }
318
+
319
+ func (p *fakeProc) Dispose() {
320
+ p.mu.Lock()
321
+ p.disposed++
322
+ hook := p.disposeHook
323
+ p.mu.Unlock()
324
+ if hook != nil {
325
+ hook()
326
+ }
327
+ }
328
+
329
+ func (p *fakeProc) disposeCount() int {
330
+ p.mu.Lock()
331
+ defer p.mu.Unlock()
332
+ return p.disposed
333
+ }
334
+
335
+ // --- assertions ---
336
+
337
+ func str(v any) string {
338
+ s, _ := v.(string)
339
+ return s
340
+ }
341
+
342
+ // norm round-trips a value through JSON, so a typed struct and a map literal compare equal.
343
+ func norm(t testing.TB, v any) any {
344
+ t.Helper()
345
+ b, err := json.Marshal(v)
346
+ if err != nil {
347
+ t.Fatalf("marshal %v: %v", v, err)
348
+ }
349
+ var out any
350
+ if err := json.Unmarshal(b, &out); err != nil {
351
+ t.Fatalf("unmarshal %s: %v", b, err)
352
+ }
353
+ return out
354
+ }
355
+
356
+ // jsonEqual is assert.deepEqual over JSON values.
357
+ func jsonEqual(t testing.TB, got, want any, msg ...any) {
358
+ t.Helper()
359
+ g, w := norm(t, got), norm(t, want)
360
+ if !reflect.DeepEqual(g, w) {
361
+ gb, _ := json.MarshalIndent(g, "", " ")
362
+ wb, _ := json.MarshalIndent(w, "", " ")
363
+ t.Fatalf("%v\n got: %s\nwant: %s", msg, gb, wb)
364
+ }
365
+ }
366
+
367
+ // eventually polls cond (the JS tests await a macrotask; the Go port runs turns on goroutines).
368
+ func eventually(t testing.TB, what string, cond func() bool) {
369
+ t.Helper()
370
+ deadline := time.Now().Add(2 * time.Second)
371
+ for time.Now().Before(deadline) {
372
+ if cond() {
373
+ return
374
+ }
375
+ time.Sleep(2 * time.Millisecond)
376
+ }
377
+ t.Fatalf("timed out waiting for %s", what)
378
+ }
379
+
380
+ // settle waits for the session's emit queue to drain: it publishes context usage on a session,
381
+ // which flushes every queued update (used where the JS tests `await` a timer).
382
+ func settle(t testing.TB, s *Session) {
383
+ t.Helper()
384
+ s.flushEmits()
385
+ }
386
+
387
+ // newTestSession is `new PiAcpSession({sessionId:'s1', cwd, mcpServers:[], proc, conn, fileCommands:[]})`.
388
+ func newTestSession(cwd string, proc Proc, conn Conn, cmds ...FileSlashCommand) *Session {
389
+ return NewSession(SessionOptions{SessionID: "s1", Cwd: cwd, McpServers: []any{}, Proc: proc, Conn: conn, FileCommands: cmds})
390
+ }
391
+
392
+ func wait(t testing.TB, ch <-chan TurnResult) TurnResult {
393
+ t.Helper()
394
+ select {
395
+ case r := <-ch:
396
+ return r
397
+ case <-time.After(2 * time.Second):
398
+ t.Fatal("prompt turn did not finish")
399
+ return TurnResult{}
400
+ }
401
+ }
402
+
403
+ func settledTurn(proc *fakeProc) {
404
+ proc.emit(Event{"type": "agent_start"})
405
+ proc.emit(Event{"type": "turn_end"})
406
+ proc.emit(Event{"type": "agent_end"})
407
+ proc.emit(Event{"type": "agent_settled"})
408
+ }
409
+
410
+ func tmp(t testing.TB) string {
411
+ t.Helper()
412
+ return t.TempDir()
413
+ }
414
+
415
+ // promptsOf waits until pi has received n prompts: the session hands a prompt to pi on its own
416
+ // goroutine, so a test that has only seen the turn settle cannot assume the call was recorded.
417
+ func promptsOf(t testing.TB, p *fakeProc, n int) []promptCall {
418
+ t.Helper()
419
+ eventually(t, "the prompt to reach pi", func() bool { return len(p.promptList()) >= n })
420
+ return p.promptList()
421
+ }
@@ -0,0 +1,28 @@
1
+ package acp
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ // TestMain isolates every test from the real configuration: HOME, PIG_HOME and the agent
10
+ // directories point into a temporary directory, so nothing reads or writes ~/.pig.
11
+ func TestMain(m *testing.M) {
12
+ root, err := os.MkdirTemp("", "pig-acp-test-*")
13
+ if err != nil {
14
+ panic(err)
15
+ }
16
+ for k, v := range map[string]string{
17
+ "HOME": filepath.Join(root, "home"), "PIG_HOME": filepath.Join(root, "pighome"),
18
+ "PIG_CODING_AGENT_DIR": filepath.Join(root, "agent"), "PI_CODING_AGENT_DIR": filepath.Join(root, "piagent"),
19
+ "XDG_CONFIG_HOME": filepath.Join(root, "xdg"), "PIG_USE_PI_DIRS": "", "PI_ACP_PI_COMMAND": "", "PIG_ACP_PIG_COMMAND": "",
20
+ "PI_ACP_ENABLE_EMBEDDED_CONTEXT": "", "PIG_ACP_ENABLE_EMBEDDED_CONTEXT": "",
21
+ "PIG_CODING_AGENT_SESSION_DIR": "", "PI_CODING_AGENT_SESSION_DIR": "",
22
+ } {
23
+ os.Setenv(k, v)
24
+ }
25
+ code := m.Run()
26
+ os.RemoveAll(root)
27
+ os.Exit(code)
28
+ }
@@ -0,0 +1,142 @@
1
+ package acp
2
+
3
+ // Cases added after the first mutation run (port/mutations.json) showed that the ported twins left
4
+ // a defect alive. They are not twins of upstream tests; each names the mutant it kills.
5
+
6
+ import (
7
+ "errors"
8
+ "path/filepath"
9
+ "strings"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ func loadFixture(t *testing.T) (*Agent, *fakeConn, *memStore, *fakeSessions, *fakeProc, string) {
15
+ t.Helper()
16
+ cwd := cwdNow(t)
17
+ proc := newFakeProc()
18
+ proc.getStateFn = func() (map[string]any, error) { return map[string]any{"thinkingLevel": "medium"}, nil }
19
+ proc.getModelsFn = func() (map[string]any, error) { return map[string]any{"models": []any{}}, nil }
20
+ conn := newFakeConn()
21
+ a, _ := testAgent(conn)
22
+ store := newMemStore(StoredSession{SessionID: "s1", Cwd: cwd, SessionFile: "/tmp/s.jsonl"})
23
+ a.store = store
24
+ sessions := &fakeSessions{session: &fakeSession{id: "s1", cwd: cwd, proc: proc}, anyID: true}
25
+ a.sessions = sessions
26
+ return a, conn, store, sessions, proc, cwd
27
+ }
28
+
29
+ func TestMutationExtras(t *testing.T) {
30
+ t.Run("a session/new whose get_state fails with a credentials error is AUTH_REQUIRED [state-auth-error-ignored]", func(t *testing.T) {
31
+ proc := newFakeProc()
32
+ proc.getStateFn = func() (map[string]any, error) { return nil, errors.New("pi get_state failed: No API key found") }
33
+ a, _ := testAgent(newFakeConn())
34
+ a.sessions = &fakeSessions{session: &fakeSession{id: "s1", cwd: cwdNow(t), proc: proc}}
35
+ _, err := a.NewSession(newSessionReq(cwdNow(t)))
36
+ if re, ok := err.(*RequestError); !ok || re.Code != -32000 {
37
+ t.Fatalf("err = %v", err)
38
+ }
39
+ })
40
+
41
+ t.Run("session/load closes a live copy of the session first [load-does-not-close-live-copy]", func(t *testing.T) {
42
+ a, _, _, sessions, _, cwd := loadFixture(t)
43
+ if _, err := a.LoadSession(LoadSessionRequest{SessionID: "s1", Cwd: cwd}); err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if len(sessions.closeCalls) == 0 || sessions.closeCalls[0] != "s1" {
47
+ t.Errorf("closeCalls = %v", sessions.closeCalls)
48
+ }
49
+ })
50
+
51
+ t.Run("session/load refreshes the session map with the cwd it was loaded with [load-does-not-refresh-map]", func(t *testing.T) {
52
+ a, _, store, _, _, cwd := loadFixture(t)
53
+ if _, err := a.LoadSession(LoadSessionRequest{SessionID: "s1", Cwd: cwd}); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ if len(store.upserts) == 0 || store.upserts[len(store.upserts)-1] != (StoredSession{SessionID: "s1", Cwd: cwd, SessionFile: "/tmp/s.jsonl"}) {
57
+ t.Errorf("upserts = %+v", store.upserts)
58
+ }
59
+ })
60
+
61
+ t.Run("a replayed failed tool result is a failed tool_call_update [tool-history-not-replayed-as-error]", func(t *testing.T) {
62
+ a, conn, _, _, proc, cwd := loadFixture(t)
63
+ proc.getMessagesFn = func() (map[string]any, error) {
64
+ return map[string]any{"messages": []any{
65
+ map[string]any{"role": "toolResult", "toolCallId": "c1", "toolName": "read", "isError": true, "content": []any{map[string]any{"type": "text", "text": "no such file"}}}}}, nil
66
+ }
67
+ if _, err := a.LoadSession(LoadSessionRequest{SessionID: "s1", Cwd: cwd}); err != nil {
68
+ t.Fatal(err)
69
+ }
70
+ up := conn.ofKind("tool_call_update")
71
+ if len(up) != 1 || up[0].Update["status"] != "failed" {
72
+ t.Fatalf("updates = %+v", up)
73
+ }
74
+ })
75
+
76
+ t.Run("the idle notice reaches the client before the prompt result [idle-notice-not-flushed-before-response]", func(t *testing.T) {
77
+ conn, proc := newFakeConn(), newFakeProc()
78
+ conn.sessionUpdateHook = func(u Update) {
79
+ if u["sessionUpdate"] == "session_info_update" {
80
+ time.Sleep(60 * time.Millisecond)
81
+ }
82
+ }
83
+ s := newTestSession(cwdNow(t), proc, conn)
84
+ p := s.Prompt("hello", nil)
85
+ settledTurn(proc)
86
+ wait(t, p)
87
+ if n := len(conn.ofKind("session_info_update")); n != 2 {
88
+ t.Errorf("%d session_info_update at the moment the turn resolved, want 2", n)
89
+ }
90
+ })
91
+
92
+ t.Run("a select answer with a leading-zero option id is cancelled [select-index-leading-zero-accepted]", func(t *testing.T) {
93
+ conn, proc := newFakeConn(), newFakeProc()
94
+ conn.nextPermission = PermissionResponse{Outcome: PermissionOutcome{Outcome: "selected", OptionID: "choice-01"}}
95
+ newTestSession(cwdNow(t), proc, conn)
96
+ proc.emit(Event{"type": "extension_ui_request", "id": "u1", "method": "select", "title": "x", "options": []any{"A", "B"}})
97
+ eventually(t, "the response", func() bool { return len(proc.uiResponses()) == 1 })
98
+ jsonEqual(t, proc.uiResponses(), []any{map[string]any{"id": "u1", "cancelled": true}})
99
+ })
100
+
101
+ t.Run("session files are listed most recent first [sessions-oldest-first]", func(t *testing.T) {
102
+ root := t.TempDir()
103
+ withAgentDir(t, root)
104
+ for id, ts := range map[string]string{"old": "2026-01-01T00:00:00.000Z", "new": "2026-03-01T00:00:00.000Z", "mid": "2026-02-01T00:00:00.000Z"} {
105
+ writeSessionFile(t, filepath.Join(root, "sessions", "--x--", id+".jsonl"), sessionHeader(id, "/cwd", ts),
106
+ jl(map[string]any{"type": "message", "id": "m1", "timestamp": ts, "message": map[string]any{"role": "user", "content": "hi"}}))
107
+ }
108
+ var ids []string
109
+ for _, s := range ListPiSessions() {
110
+ ids = append(ids, s.SessionID)
111
+ }
112
+ if strings.Join(ids, ",") != "new,mid,old" {
113
+ t.Errorf("order = %v", ids)
114
+ }
115
+ })
116
+ }
117
+
118
+ func TestBase64ByteLength(t *testing.T) {
119
+ t.Run("byte lengths of padded base64 data [base64-length-padding]", func(t *testing.T) {
120
+ for in, want := range map[string]int{"": 0, "YQ==": 1, "YWI=": 2, "YWJj": 3, "YWJjZA==": 4} {
121
+ if got := base64ByteLength(in); got != want {
122
+ t.Errorf("%q: got %d, want %d", in, got, want)
123
+ }
124
+ }
125
+ })
126
+ }
127
+
128
+ func TestExportNeedsMessages(t *testing.T) {
129
+ t.Run("/export with a session file but no messages says there is nothing to export [export-empty-session-exported]", func(t *testing.T) {
130
+ file := filepath.Join(t.TempDir(), "s.jsonl")
131
+ write(t, file, "{\"type\":\"session\"}\n")
132
+ proc := newFakeProc()
133
+ proc.getStateFn = func() (map[string]any, error) {
134
+ return map[string]any{"sessionFile": file, "messageCount": 0}, nil
135
+ }
136
+ a, conn := builtinAgent(t, proc, cwdNow(t))
137
+ runCmd(t, a, "/export")
138
+ if got := lastChunkText(t, conn); !strings.HasPrefix(got, "Nothing to export yet (no session messages)") {
139
+ t.Errorf("text = %q", got)
140
+ }
141
+ })
142
+ }
@@ -0,0 +1,77 @@
1
+ package acp
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "strings"
7
+ )
8
+
9
+ func sharedPiDirs() bool { return os.Getenv("PIG_USE_PI_DIRS") == "1" }
10
+
11
+ func userHome() string {
12
+ if h, err := os.UserHomeDir(); err == nil && h != "" {
13
+ return h
14
+ }
15
+ return os.Getenv("HOME")
16
+ }
17
+
18
+ // expandTilde expands a leading "~" or "~/" as pig does for its directory variables
19
+ // (internal/codingagent ExpandTildePath). An editor passes env values unexpanded.
20
+ func expandTilde(p string) string {
21
+ if p == "~" {
22
+ return userHome()
23
+ }
24
+ if rest, ok := strings.CutPrefix(p, "~/"); ok {
25
+ return filepath.Join(userHome(), rest)
26
+ }
27
+ return p
28
+ }
29
+
30
+ // pigHome is PiG's config root: PIG_HOME, else XDG_CONFIG_HOME/pig, else ~/.pig. PiG keeps its own
31
+ // state here even in shared mode (PIG_USE_PI_DIRS=1), which shares only the agent and project
32
+ // directories with Pi.
33
+ func pigHome() string {
34
+ if h := os.Getenv("PIG_HOME"); h != "" {
35
+ return expandTilde(h)
36
+ }
37
+ if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
38
+ return filepath.Join(expandTilde(x), "pig")
39
+ }
40
+ return filepath.Join(userHome(), ".pig")
41
+ }
42
+
43
+ // AgentDir is PiG's agent directory: PIG_CODING_AGENT_DIR, else <PIG_HOME>/agent; with
44
+ // PIG_USE_PI_DIRS=1 it is Pi's (PI_CODING_AGENT_DIR, else ~/.pi/agent).
45
+ func AgentDir() string {
46
+ if sharedPiDirs() {
47
+ if d := os.Getenv("PI_CODING_AGENT_DIR"); d != "" {
48
+ return absPath(expandTilde(d))
49
+ }
50
+ return filepath.Join(userHome(), ".pi", "agent")
51
+ }
52
+ if d := os.Getenv("PIG_CODING_AGENT_DIR"); d != "" {
53
+ return absPath(expandTilde(d))
54
+ }
55
+ return filepath.Join(pigHome(), "agent")
56
+ }
57
+
58
+ func absPath(p string) string {
59
+ if a, err := filepath.Abs(p); err == nil {
60
+ return a
61
+ }
62
+ return p
63
+ }
64
+
65
+ // ProjectDirName is ".pig", or ".pi" when PIG_USE_PI_DIRS=1.
66
+ func ProjectDirName() string {
67
+ if sharedPiDirs() {
68
+ return ".pi"
69
+ }
70
+ return ".pig"
71
+ }
72
+
73
+ // AcpDir is where the adapter keeps its own state, apart from pig's agent directory.
74
+ func AcpDir() string { return filepath.Join(pigHome(), "pig-acp") }
75
+
76
+ // SessionMapPath is the file the default store uses.
77
+ func SessionMapPath() string { return filepath.Join(AcpDir(), "session-map.json") }