@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,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") }
|