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