@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,215 @@
|
|
|
1
|
+
package acp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"sync"
|
|
8
|
+
"testing"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// fakeSession is the structural fake session of the JS tests ({sessionId, proc, ...}).
|
|
12
|
+
type fakeSession struct {
|
|
13
|
+
mu sync.Mutex
|
|
14
|
+
id, cwd string
|
|
15
|
+
proc Proc
|
|
16
|
+
promptFn func(msg string, images []Image) TurnResult
|
|
17
|
+
prompts []promptCall
|
|
18
|
+
startupInfo []string
|
|
19
|
+
startupSent int
|
|
20
|
+
publishFn func()
|
|
21
|
+
cancelled bool
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func (s *fakeSession) ID() string { return s.id }
|
|
25
|
+
func (s *fakeSession) Cwd() string { return s.cwd }
|
|
26
|
+
func (s *fakeSession) Proc() Proc { return s.proc }
|
|
27
|
+
func (s *fakeSession) Cancel() error { s.cancelled = true; return nil }
|
|
28
|
+
func (s *fakeSession) WasCancelRequested() bool { return false }
|
|
29
|
+
func (s *fakeSession) PublishContextUsage() {
|
|
30
|
+
if s.publishFn != nil {
|
|
31
|
+
s.publishFn()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
func (s *fakeSession) SetStartupInfo(text string) {
|
|
35
|
+
s.mu.Lock()
|
|
36
|
+
s.startupInfo = append(s.startupInfo, text)
|
|
37
|
+
s.mu.Unlock()
|
|
38
|
+
}
|
|
39
|
+
func (s *fakeSession) SendStartupInfoIfPending() {
|
|
40
|
+
s.mu.Lock()
|
|
41
|
+
s.startupSent++
|
|
42
|
+
s.mu.Unlock()
|
|
43
|
+
}
|
|
44
|
+
func (s *fakeSession) Prompt(message string, images []Image) <-chan TurnResult {
|
|
45
|
+
s.mu.Lock()
|
|
46
|
+
s.prompts = append(s.prompts, promptCall{message, images})
|
|
47
|
+
fn := s.promptFn
|
|
48
|
+
s.mu.Unlock()
|
|
49
|
+
ch := make(chan TurnResult, 1)
|
|
50
|
+
if fn != nil {
|
|
51
|
+
ch <- fn(message, images)
|
|
52
|
+
} else {
|
|
53
|
+
ch <- TurnResult{Reason: StopEndTurn}
|
|
54
|
+
}
|
|
55
|
+
return ch
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// fakeSessions is the FakeSessions class of the JS tests.
|
|
59
|
+
type fakeSessions struct {
|
|
60
|
+
mu sync.Mutex
|
|
61
|
+
session ActiveSession
|
|
62
|
+
restored ActiveSession
|
|
63
|
+
closeCalls []string
|
|
64
|
+
closeOthers []string
|
|
65
|
+
createErr error
|
|
66
|
+
// build makes the session GetOrCreate registers (session-restore.test.ts).
|
|
67
|
+
build func(id string, p SessionCreateParams) ActiveSession
|
|
68
|
+
// anyID makes MaybeGet/Get return the session for every id (agent tests that ignore the id).
|
|
69
|
+
anyID bool
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func (f *fakeSessions) Create(SessionCreateParams) (ActiveSession, error) {
|
|
73
|
+
if f.createErr != nil {
|
|
74
|
+
return nil, f.createErr
|
|
75
|
+
}
|
|
76
|
+
return f.session, nil
|
|
77
|
+
}
|
|
78
|
+
func (f *fakeSessions) match(id string) ActiveSession {
|
|
79
|
+
f.mu.Lock()
|
|
80
|
+
defer f.mu.Unlock()
|
|
81
|
+
if f.restored != nil && f.restored.ID() == id {
|
|
82
|
+
return f.restored
|
|
83
|
+
}
|
|
84
|
+
if f.session != nil && (f.anyID || f.session.ID() == id) {
|
|
85
|
+
return f.session
|
|
86
|
+
}
|
|
87
|
+
return nil
|
|
88
|
+
}
|
|
89
|
+
func (f *fakeSessions) MaybeGet(id string) ActiveSession { return f.match(id) }
|
|
90
|
+
func (f *fakeSessions) Get(id string) (ActiveSession, error) {
|
|
91
|
+
if s := f.match(id); s != nil {
|
|
92
|
+
return s, nil
|
|
93
|
+
}
|
|
94
|
+
return nil, errors.New("Unknown sessionId: " + id)
|
|
95
|
+
}
|
|
96
|
+
func (f *fakeSessions) GetOrCreate(id string, p SessionCreateParams) ActiveSession {
|
|
97
|
+
f.mu.Lock()
|
|
98
|
+
defer f.mu.Unlock()
|
|
99
|
+
if f.restored == nil && f.build != nil {
|
|
100
|
+
f.restored = f.build(id, p)
|
|
101
|
+
}
|
|
102
|
+
return f.restored
|
|
103
|
+
}
|
|
104
|
+
func (f *fakeSessions) Close(id string) {
|
|
105
|
+
f.mu.Lock()
|
|
106
|
+
f.closeCalls = append(f.closeCalls, id)
|
|
107
|
+
f.mu.Unlock()
|
|
108
|
+
}
|
|
109
|
+
func (f *fakeSessions) CloseAllExcept(keep string) {
|
|
110
|
+
f.mu.Lock()
|
|
111
|
+
f.closeOthers = append(f.closeOthers, keep)
|
|
112
|
+
f.mu.Unlock()
|
|
113
|
+
}
|
|
114
|
+
func (f *fakeSessions) DisposeAll() {}
|
|
115
|
+
|
|
116
|
+
// memStore is a Store double recording calls; the JS tests stub `store` with plain objects.
|
|
117
|
+
type memStore struct {
|
|
118
|
+
mu sync.Mutex
|
|
119
|
+
entries map[string]StoredSession
|
|
120
|
+
upserts []StoredSession
|
|
121
|
+
deletes []string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func newMemStore(entries ...StoredSession) *memStore {
|
|
125
|
+
s := &memStore{entries: map[string]StoredSession{}}
|
|
126
|
+
for _, e := range entries {
|
|
127
|
+
s.entries[e.SessionID] = e
|
|
128
|
+
}
|
|
129
|
+
return s
|
|
130
|
+
}
|
|
131
|
+
func (s *memStore) Get(id string) *StoredSession {
|
|
132
|
+
s.mu.Lock()
|
|
133
|
+
defer s.mu.Unlock()
|
|
134
|
+
if e, ok := s.entries[id]; ok {
|
|
135
|
+
return &e
|
|
136
|
+
}
|
|
137
|
+
return nil
|
|
138
|
+
}
|
|
139
|
+
func (s *memStore) Upsert(e StoredSession) {
|
|
140
|
+
s.mu.Lock()
|
|
141
|
+
defer s.mu.Unlock()
|
|
142
|
+
s.upserts = append(s.upserts, e)
|
|
143
|
+
s.entries[e.SessionID] = e
|
|
144
|
+
}
|
|
145
|
+
func (s *memStore) Delete(id string) {
|
|
146
|
+
s.mu.Lock()
|
|
147
|
+
defer s.mu.Unlock()
|
|
148
|
+
s.deletes = append(s.deletes, id)
|
|
149
|
+
delete(s.entries, id)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// scheduler replaces Agent.schedule: it collects the deferred work instead of running it,
|
|
153
|
+
// as `t.mock.method(globalThis, 'setTimeout', ...)` does, and runs it on demand.
|
|
154
|
+
type scheduler struct {
|
|
155
|
+
mu sync.Mutex
|
|
156
|
+
fns []func()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
func (s *scheduler) add(fn func()) {
|
|
160
|
+
s.mu.Lock()
|
|
161
|
+
s.fns = append(s.fns, fn)
|
|
162
|
+
s.mu.Unlock()
|
|
163
|
+
}
|
|
164
|
+
func (s *scheduler) count() int {
|
|
165
|
+
s.mu.Lock()
|
|
166
|
+
defer s.mu.Unlock()
|
|
167
|
+
return len(s.fns)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// drain is drainScheduledWork: it runs what was scheduled after session/new or session/load.
|
|
171
|
+
func (s *scheduler) drain() {
|
|
172
|
+
for {
|
|
173
|
+
s.mu.Lock()
|
|
174
|
+
fns := s.fns
|
|
175
|
+
s.fns = nil
|
|
176
|
+
s.mu.Unlock()
|
|
177
|
+
if len(fns) == 0 {
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
for _, fn := range fns {
|
|
181
|
+
fn()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// testAgent is `new PiAcpAgent(conn)` with the seams the twins replace.
|
|
187
|
+
func testAgent(conn Conn) (*Agent, *scheduler) {
|
|
188
|
+
a := NewAgent(conn)
|
|
189
|
+
sch := &scheduler{}
|
|
190
|
+
a.schedule = sch.add
|
|
191
|
+
a.store = newMemStore()
|
|
192
|
+
return a, sch
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// withAgentDir points PiG's agent directory at dir (PI_CODING_AGENT_DIR in the original).
|
|
196
|
+
func withAgentDir(t *testing.T, dir string) {
|
|
197
|
+
t.Helper()
|
|
198
|
+
t.Setenv("PIG_USE_PI_DIRS", "")
|
|
199
|
+
t.Setenv("PIG_CODING_AGENT_DIR", dir)
|
|
200
|
+
t.Setenv("PIG_HOME", filepath.Join(t.TempDir(), "pighome"))
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func writeSessionFile(t *testing.T, path string, lines ...string) {
|
|
204
|
+
t.Helper()
|
|
205
|
+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
206
|
+
t.Fatal(err)
|
|
207
|
+
}
|
|
208
|
+
body := ""
|
|
209
|
+
for _, l := range lines {
|
|
210
|
+
body += l + "\n"
|
|
211
|
+
}
|
|
212
|
+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
213
|
+
t.Fatal(err)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
package acp
|
|
2
|
+
|
|
3
|
+
// Twins of test/component/session-list-and-load.test.ts, session-load-toolresult.test.ts,
|
|
4
|
+
// session-list-scoped.test.ts, session-list-custom-session-dir.test.ts,
|
|
5
|
+
// session-title-long-session.test.ts, session-updatedAt-message-only.test.ts and
|
|
6
|
+
// test/unit/startup-info-*.test.ts.
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"encoding/json"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
"time"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func jl(v any) string {
|
|
18
|
+
b, _ := json.Marshal(v)
|
|
19
|
+
return string(b)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func sessionHeader(id, cwd, ts string) string {
|
|
23
|
+
return jl(map[string]any{"type": "session", "version": 3, "id": id, "timestamp": ts, "cwd": cwd})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestListAndLoad(t *testing.T) {
|
|
27
|
+
tw(t, "component/session-list-and-load", "PiAcpAgent: listSessions lists pi sessions and loadSession replays history", func(t *testing.T) {
|
|
28
|
+
root := t.TempDir()
|
|
29
|
+
sessionFile := filepath.Join(root, "sessions", "--tmp--project--", "0000_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jsonl")
|
|
30
|
+
writeSessionFile(t, sessionFile,
|
|
31
|
+
sessionHeader("sess-1", "/tmp/project", "2026-02-11T00:00:00.000Z"),
|
|
32
|
+
jl(map[string]any{"type": "message", "id": "a1b2c3d4", "parentId": nil, "timestamp": "2026-02-11T00:00:01.000Z", "message": map[string]any{"role": "user", "content": "Hello"}}),
|
|
33
|
+
jl(map[string]any{"type": "message", "id": "b2c3d4e5", "parentId": "a1b2c3d4", "timestamp": "2026-02-11T00:00:02.000Z", "message": map[string]any{"role": "assistant", "content": []any{map[string]any{"type": "text", "text": "Hi there!"}}}}),
|
|
34
|
+
jl(map[string]any{"type": "session_info", "id": "c3d4e5f6", "parentId": "b2c3d4e5", "timestamp": "2026-02-11T00:00:03.000Z", "name": "My Named Session"}))
|
|
35
|
+
withAgentDir(t, root)
|
|
36
|
+
conn := newFakeConn()
|
|
37
|
+
a, sch := testAgent(conn)
|
|
38
|
+
a.store = NewFileStore(filepath.Join(root, "map.json"))
|
|
39
|
+
|
|
40
|
+
listed, err := a.ListSessions(ListSessionsRequest{})
|
|
41
|
+
if err != nil {
|
|
42
|
+
t.Fatal(err)
|
|
43
|
+
}
|
|
44
|
+
var found *SessionInfo
|
|
45
|
+
for i := range listed.Sessions {
|
|
46
|
+
if listed.Sessions[i].SessionID == "sess-1" {
|
|
47
|
+
found = &listed.Sessions[i]
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if found == nil || found.Cwd != "/tmp/project" || found.Title == nil || *found.Title != "My Named Session" {
|
|
51
|
+
t.Fatalf("listed = %+v", listed.Sessions)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
proc := newFakeProc()
|
|
55
|
+
proc.getMessagesFn = func() (map[string]any, error) {
|
|
56
|
+
return map[string]any{"messages": []any{
|
|
57
|
+
map[string]any{"role": "user", "content": "Hello"},
|
|
58
|
+
map[string]any{"role": "assistant", "content": []any{map[string]any{"type": "text", "text": "Hi there!"}}}}}, nil
|
|
59
|
+
}
|
|
60
|
+
proc.getLevelsFn = func() ([]string, error) { return []string{"medium"}, nil }
|
|
61
|
+
proc.getModelsFn = func() (map[string]any, error) { return map[string]any{"models": []any{}}, nil }
|
|
62
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"thinkingLevel": "medium"}, nil }
|
|
63
|
+
proc.sessionStats = SessionStats{"contextUsage": map[string]any{"tokens": 12345, "contextWindow": 200000}}
|
|
64
|
+
a.spawn = func(p SpawnParams) (Proc, error) {
|
|
65
|
+
if !strings.HasSuffix(p.SessionPath, "/0000_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jsonl") {
|
|
66
|
+
t.Errorf("session path = %q", p.SessionPath)
|
|
67
|
+
}
|
|
68
|
+
return proc, nil
|
|
69
|
+
}
|
|
70
|
+
a.sessions = NewSessionManager(a.spawn, a.store)
|
|
71
|
+
if _, err := a.LoadSession(LoadSessionRequest{SessionID: "sess-1", Cwd: "/tmp/project", McpServers: []any{}}); err != nil {
|
|
72
|
+
t.Fatal(err)
|
|
73
|
+
}
|
|
74
|
+
hasKind := func(kind, text string) bool {
|
|
75
|
+
for _, u := range conn.ofKind(kind) {
|
|
76
|
+
if c, _ := u.Update["content"].(map[string]any); c["text"] == text {
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
if !hasKind("user_message_chunk", "Hello") || !hasKind("agent_message_chunk", "Hi there!") {
|
|
83
|
+
t.Errorf("history not replayed: %v", conn.kinds())
|
|
84
|
+
}
|
|
85
|
+
if len(usageUpdates(conn)) != 0 {
|
|
86
|
+
t.Error("usage published before session/load returned")
|
|
87
|
+
}
|
|
88
|
+
sch.drain()
|
|
89
|
+
got := conn.ofKind("usage_update")
|
|
90
|
+
if len(got) != 1 || got[0].SessionID != "sess-1" {
|
|
91
|
+
t.Fatalf("usage updates = %+v", got)
|
|
92
|
+
}
|
|
93
|
+
jsonEqual(t, got[0].Update, map[string]any{"sessionUpdate": "usage_update", "used": 12345, "size": 200000})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
tw(t, "component/session-load-toolresult", "PiAcpAgent: loadSession replays toolResult as tool_call + tool_call_update", func(t *testing.T) {
|
|
97
|
+
proc := newFakeProc()
|
|
98
|
+
proc.getMessagesFn = func() (map[string]any, error) {
|
|
99
|
+
return map[string]any{"messages": []any{map[string]any{
|
|
100
|
+
"role": "toolResult", "toolCallId": "call_1", "toolName": "bash", "args": map[string]any{"command": "echo hello"},
|
|
101
|
+
"content": []any{map[string]any{"type": "text", "text": "hello from bash"}}, "isError": false}}}, nil
|
|
102
|
+
}
|
|
103
|
+
proc.getLevelsFn = func() ([]string, error) { return []string{"medium"}, nil }
|
|
104
|
+
proc.getModelsFn = func() (map[string]any, error) { return map[string]any{"models": []any{}}, nil }
|
|
105
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"thinkingLevel": "medium"}, nil }
|
|
106
|
+
conn := newFakeConn()
|
|
107
|
+
a, _ := testAgent(conn)
|
|
108
|
+
a.store = newMemStore(StoredSession{SessionID: "s1", Cwd: "/tmp/project", SessionFile: "/tmp/s.jsonl"})
|
|
109
|
+
a.spawn = func(SpawnParams) (Proc, error) { return proc, nil }
|
|
110
|
+
a.sessions = NewSessionManager(a.spawn, a.store)
|
|
111
|
+
if _, err := a.LoadSession(LoadSessionRequest{SessionID: "s1", Cwd: "/tmp/project", McpServers: []any{}}); err != nil {
|
|
112
|
+
t.Fatal(err)
|
|
113
|
+
}
|
|
114
|
+
tc := conn.ofKind("tool_call")
|
|
115
|
+
tu := conn.ofKind("tool_call_update")
|
|
116
|
+
if len(tc) == 0 || len(tu) == 0 {
|
|
117
|
+
t.Fatalf("kinds = %v", conn.kinds())
|
|
118
|
+
}
|
|
119
|
+
u := tc[0].Update
|
|
120
|
+
if u["toolCallId"] != "call_1" || u["title"] != "echo hello" || u["kind"] != "execute" {
|
|
121
|
+
t.Errorf("tool_call = %v", u)
|
|
122
|
+
}
|
|
123
|
+
jsonEqual(t, u["content"], []any{map[string]any{"type": "terminal", "terminalId": "call_1"}})
|
|
124
|
+
jsonEqual(t, u["_meta"], map[string]any{"terminal_info": map[string]any{"terminal_id": "call_1", "cwd": "/tmp/project"}})
|
|
125
|
+
if _, ok := u["rawOutput"]; ok {
|
|
126
|
+
t.Error("rawOutput must be undefined")
|
|
127
|
+
}
|
|
128
|
+
v := tu[0].Update
|
|
129
|
+
if v["toolCallId"] != "call_1" || v["status"] != "completed" {
|
|
130
|
+
t.Errorf("tool_call_update = %v", v)
|
|
131
|
+
}
|
|
132
|
+
jsonEqual(t, v["_meta"], map[string]any{
|
|
133
|
+
"terminal_output": map[string]any{"terminal_id": "call_1", "data": "hello from bash"},
|
|
134
|
+
"terminal_exit": map[string]any{"terminal_id": "call_1", "exit_code": 0, "signal": nil}})
|
|
135
|
+
if _, ok := v["rawOutput"]; ok {
|
|
136
|
+
t.Error("rawOutput must be undefined")
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func TestListPiSessions(t *testing.T) {
|
|
142
|
+
tw(t, "component/session-list-scoped", "PiAcpAgent: listSessions defaults to lastSessionCwd when cwd param is omitted", func(t *testing.T) {
|
|
143
|
+
root := t.TempDir()
|
|
144
|
+
for name, id := range map[string]string{"a": "sess-a", "b": "sess-b"} {
|
|
145
|
+
writeSessionFile(t, filepath.Join(root, "sessions", "--"+name+"--", strings.ToUpper(name)+".jsonl"),
|
|
146
|
+
sessionHeader(id, "/cwd/"+name, "2026-01-01T00:00:00.000Z"),
|
|
147
|
+
jl(map[string]any{"type": "session_info", "id": name + "1b2c3d4", "parentId": nil, "timestamp": "2026-01-01T00:00:01.000Z", "name": strings.ToUpper(name)}))
|
|
148
|
+
}
|
|
149
|
+
withAgentDir(t, root)
|
|
150
|
+
a, _ := testAgent(newFakeConn())
|
|
151
|
+
a.lastSessionCwd = "/cwd/a"
|
|
152
|
+
listed, err := a.ListSessions(ListSessionsRequest{})
|
|
153
|
+
if err != nil {
|
|
154
|
+
t.Fatal(err)
|
|
155
|
+
}
|
|
156
|
+
if len(listed.Sessions) != 1 || listed.Sessions[0].SessionID != "sess-a" {
|
|
157
|
+
t.Fatalf("sessions = %+v", listed.Sessions)
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
tw(t, "component/session-list-custom-session-dir", "listPiSessions: respects sessionDir from pi settings.json", func(t *testing.T) {
|
|
162
|
+
root := t.TempDir()
|
|
163
|
+
custom := filepath.Join(root, "somewhere-else", "--p--")
|
|
164
|
+
write(t, filepath.Join(root, "settings.json"), jl(map[string]any{"sessionDir": filepath.Join(root, "somewhere-else")}))
|
|
165
|
+
writeSessionFile(t, filepath.Join(custom, "s.jsonl"),
|
|
166
|
+
sessionHeader("sess-custom", "/tmp/project", "2026-01-01T00:00:00.000Z"),
|
|
167
|
+
jl(map[string]any{"type": "message", "id": "m1", "parentId": nil, "timestamp": "2026-01-01T00:00:01.000Z", "message": map[string]any{"role": "user", "content": "hi"}}))
|
|
168
|
+
withAgentDir(t, root)
|
|
169
|
+
var got *PiSessionListItem
|
|
170
|
+
for _, s := range ListPiSessions() {
|
|
171
|
+
if s.SessionID == "sess-custom" {
|
|
172
|
+
s := s
|
|
173
|
+
got = &s
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if got == nil || got.SessionFile != filepath.Join(custom, "s.jsonl") {
|
|
177
|
+
t.Fatalf("got %+v", got)
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
tw(t, "component/session-title-long-session", "listPiSessions: finds session_info.name even when it is outside the tail window", func(t *testing.T) {
|
|
182
|
+
root := t.TempDir()
|
|
183
|
+
header := sessionHeader("sess-1", "/tmp/project", "2026-01-01T00:00:00.000Z")
|
|
184
|
+
info := jl(map[string]any{"type": "session_info", "id": "i1", "parentId": nil, "timestamp": "2026-01-01T00:00:01.000Z", "name": "Named Early"})
|
|
185
|
+
filler := jl(map[string]any{"type": "message", "id": "m", "parentId": nil, "timestamp": "2026-01-01T00:00:02.000Z", "message": map[string]any{"role": "user", "content": strings.Repeat("x", 2000)}})
|
|
186
|
+
lines := []string{header, info}
|
|
187
|
+
for i := 0; i < 400; i++ {
|
|
188
|
+
lines = append(lines, filler)
|
|
189
|
+
}
|
|
190
|
+
writeSessionFile(t, filepath.Join(root, "sessions", "--p--", "s.jsonl"), lines...)
|
|
191
|
+
withAgentDir(t, root)
|
|
192
|
+
var got *PiSessionListItem
|
|
193
|
+
for _, s := range ListPiSessions() {
|
|
194
|
+
if s.SessionID == "sess-1" {
|
|
195
|
+
s := s
|
|
196
|
+
got = &s
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if got == nil || got.Title == nil || *got.Title != "Named Early" {
|
|
200
|
+
t.Fatalf("got %+v", got)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
tw(t, "component/session-updatedAt-message-only", "listPiSessions: updatedAt prefers last message timestamp over later non-message entries", func(t *testing.T) {
|
|
205
|
+
root := t.TempDir()
|
|
206
|
+
writeSessionFile(t, filepath.Join(root, "sessions", "--p--", "s.jsonl"),
|
|
207
|
+
sessionHeader("sess-1", "/tmp/project", "2026-01-01T00:00:00.000Z"),
|
|
208
|
+
jl(map[string]any{"type": "message", "id": "a1b2c3d4", "parentId": nil, "timestamp": "2026-01-01T00:00:02.000Z", "message": map[string]any{"role": "user", "content": "hi"}}),
|
|
209
|
+
jl(map[string]any{"type": "session_info", "id": "b1b2c3d4", "parentId": "a1b2c3d4", "timestamp": "2026-01-01T00:00:10.000Z", "name": "named"}))
|
|
210
|
+
withAgentDir(t, root)
|
|
211
|
+
var matches []PiSessionListItem
|
|
212
|
+
for _, s := range ListPiSessions() {
|
|
213
|
+
if s.SessionID == "sess-1" {
|
|
214
|
+
matches = append(matches, s)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if len(matches) != 1 || matches[0].UpdatedAt == nil || *matches[0].UpdatedAt != "2026-01-01T00:00:02.000Z" {
|
|
218
|
+
t.Fatalf("matches = %+v", matches)
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
func TestStartupInfo(t *testing.T) {
|
|
224
|
+
agentWithSession := func(t *testing.T, cwd string, proc *fakeProc) (*Agent, *scheduler, *fakeSession) {
|
|
225
|
+
conn := newFakeConn()
|
|
226
|
+
sess := &fakeSession{id: "s1", cwd: cwd, proc: proc}
|
|
227
|
+
a, sch := testAgent(conn)
|
|
228
|
+
a.sessions = &fakeSessions{session: sess}
|
|
229
|
+
return a, sch, sess
|
|
230
|
+
}
|
|
231
|
+
basicProc := func() *fakeProc {
|
|
232
|
+
p := newFakeProc()
|
|
233
|
+
p.getLevelsFn = func() ([]string, error) { return allLevels, nil }
|
|
234
|
+
p.getModelsFn = func() (map[string]any, error) { return models([3]string{"test", "model", "model"}), nil }
|
|
235
|
+
p.getStateFn = func() (map[string]any, error) {
|
|
236
|
+
return map[string]any{"thinkingLevel": "medium", "model": map[string]any{"provider": "test", "id": "model"}}, nil
|
|
237
|
+
}
|
|
238
|
+
p.getCommandsFn = func() (map[string]any, error) { return map[string]any{"commands": []any{}}, nil }
|
|
239
|
+
return p
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
tw(t, "unit/startup-info-env", "PiAcpAgent: quietStartup=true disables startup info generation/emission", func(t *testing.T) {
|
|
243
|
+
dir := t.TempDir()
|
|
244
|
+
write(t, filepath.Join(dir, "settings.json"), jl(map[string]any{"quietStartup": true}))
|
|
245
|
+
withAgentDir(t, dir)
|
|
246
|
+
a, sch, sess := agentWithSession(t, cwdNow(t), basicProc())
|
|
247
|
+
res, err := a.NewSession(newSessionReq(cwdNow(t)))
|
|
248
|
+
if err != nil {
|
|
249
|
+
t.Fatal(err)
|
|
250
|
+
}
|
|
251
|
+
// PiG has no npm update check (`pig update` uses its own signed manifest), so the
|
|
252
|
+
// "New version available" branch of the original does not exist: see the skipped twin.
|
|
253
|
+
if startupInfoOf(res.Meta) != nil {
|
|
254
|
+
t.Errorf("startupInfo = %v", res.Meta)
|
|
255
|
+
}
|
|
256
|
+
if len(sess.startupInfo) != 0 {
|
|
257
|
+
t.Error("setStartupInfo was called")
|
|
258
|
+
}
|
|
259
|
+
if sch.count() != 1 {
|
|
260
|
+
t.Errorf("%d scheduled tasks, want the available_commands_update only", sch.count())
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
t.Run("PiAcpAgent: quietStartup=true still surfaces a New version available notice", func(t *testing.T) {
|
|
265
|
+
t.Skip("gap: pi-acp asks the npm registry for a newer pi (`npm view @earendil-works/pi-coding-agent version`) and surfaces the " +
|
|
266
|
+
"notice even with quietStartup. PiG updates through `pig update` and a signed release manifest, not npm; an editor adapter " +
|
|
267
|
+
"must not add a network lookup to session/new (PIG_OFFLINE). The adapter emits no update notice. Owner decision if wanted.")
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
tw(t, "unit/startup-info-load-session", "PiAcpAgent: does not emit startup info on loadSession", func(t *testing.T) {
|
|
271
|
+
proc := newFakeProc()
|
|
272
|
+
proc.getLevelsFn = func() ([]string, error) { return []string{"medium"}, nil }
|
|
273
|
+
proc.getModelsFn = func() (map[string]any, error) { return map[string]any{"models": []any{}}, nil }
|
|
274
|
+
proc.getStateFn = func() (map[string]any, error) { return map[string]any{"thinkingLevel": "medium"}, nil }
|
|
275
|
+
conn := newFakeConn()
|
|
276
|
+
a, sch := testAgent(conn)
|
|
277
|
+
a.store = newMemStore(StoredSession{SessionID: "s1", Cwd: "/tmp/project", SessionFile: "/tmp/s.jsonl"})
|
|
278
|
+
a.spawn = func(SpawnParams) (Proc, error) { return proc, nil }
|
|
279
|
+
a.sessions = NewSessionManager(a.spawn, a.store)
|
|
280
|
+
res, err := a.LoadSession(LoadSessionRequest{SessionID: "s1", Cwd: "/tmp/project", McpServers: []any{}})
|
|
281
|
+
if err != nil {
|
|
282
|
+
t.Fatal(err)
|
|
283
|
+
}
|
|
284
|
+
if startupInfoOf(res.Meta) != nil {
|
|
285
|
+
t.Errorf("startupInfo = %v", res.Meta)
|
|
286
|
+
}
|
|
287
|
+
if sch.count() != 1 {
|
|
288
|
+
t.Errorf("%d scheduled tasks, want the available_commands_update only", sch.count())
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
tw(t, "unit/startup-info-project-packages", "PiAcpAgent: startup info includes project-level packages from .pi/settings.json", func(t *testing.T) {
|
|
293
|
+
agentDir := t.TempDir()
|
|
294
|
+
write(t, filepath.Join(agentDir, "settings.json"), jl(map[string]any{"packages": []string{"npm:global-ext"}}))
|
|
295
|
+
withAgentDir(t, agentDir)
|
|
296
|
+
project := t.TempDir()
|
|
297
|
+
// PiG's project directory is .pig (the original reads .pi).
|
|
298
|
+
if err := os.MkdirAll(filepath.Join(project, ".pig"), 0o755); err != nil {
|
|
299
|
+
t.Fatal(err)
|
|
300
|
+
}
|
|
301
|
+
write(t, filepath.Join(project, ".pig", "settings.json"), jl(map[string]any{"packages": []string{"/path/to/local-extension"}}))
|
|
302
|
+
a, _, _ := agentWithSession(t, project, basicProc())
|
|
303
|
+
res, err := a.NewSession(newSessionReq(project))
|
|
304
|
+
if err != nil {
|
|
305
|
+
t.Fatal(err)
|
|
306
|
+
}
|
|
307
|
+
info, _ := startupInfoOf(res.Meta).(string)
|
|
308
|
+
if !strings.Contains(info, "npm:global-ext") || !strings.Contains(info, "/path/to/local-extension") {
|
|
309
|
+
t.Errorf("startup info = %q", info)
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
t.Run("PiAcpAgent: session/new startup info is sent after the response, not before", func(t *testing.T) {
|
|
314
|
+
a, sch, sess := agentWithSession(t, cwdNow(t), basicProc())
|
|
315
|
+
if _, err := a.NewSession(newSessionReq(cwdNow(t))); err != nil {
|
|
316
|
+
t.Fatal(err)
|
|
317
|
+
}
|
|
318
|
+
if sess.startupSent != 0 {
|
|
319
|
+
t.Error("startup info was sent before the response")
|
|
320
|
+
}
|
|
321
|
+
sch.drain()
|
|
322
|
+
if sess.startupSent != 1 {
|
|
323
|
+
t.Errorf("startup info sent %d times after the response", sess.startupSent)
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
_ = time.Second
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// startupInfoOf reads _meta.piAcp.startupInfo without panicking on a missing _meta.
|
|
330
|
+
func startupInfoOf(meta map[string]any) any {
|
|
331
|
+
m, _ := meta["piAcp"].(map[string]any)
|
|
332
|
+
return m["startupInfo"]
|
|
333
|
+
}
|