@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,429 @@
1
+ package acp
2
+
3
+ // Twins of test/unit/session-restore.test.ts, session-delete.test.ts, new-session-*.test.ts,
4
+ // startup-info-*.test.ts and the load-configuration cases of model-thinking-levels.test.ts.
5
+
6
+ import (
7
+ "errors"
8
+ "fmt"
9
+ "os"
10
+ "path/filepath"
11
+ "reflect"
12
+ "strings"
13
+ "testing"
14
+ )
15
+
16
+ func headerLine(id, cwd string) string {
17
+ return fmt.Sprintf(`{"type":"session","version":3,"id":%q,"timestamp":"2026-06-16T00:00:00.000Z","cwd":%q}`, id, cwd)
18
+ }
19
+
20
+ func TestSessionRestore(t *testing.T) {
21
+ tw(t, "unit/session-restore", "PiAcpAgent: prompt auto-restores a missing session from SessionStore", func(t *testing.T) {
22
+ conn := newFakeConn()
23
+ var spawns []SpawnParams
24
+ var prompted []promptCall
25
+ store := newMemStore(StoredSession{SessionID: "stored-session", Cwd: "/tmp/store-project", SessionFile: "/tmp/store-project/session.jsonl"})
26
+ a, _ := testAgent(conn)
27
+ a.store = store
28
+ a.spawn = func(p SpawnParams) (Proc, error) { spawns = append(spawns, p); return newFakeProc(), nil }
29
+ a.sessions = &fakeSessions{build: func(id string, p SessionCreateParams) ActiveSession {
30
+ return &fakeSession{id: id, cwd: p.Cwd, proc: p.Proc, promptFn: func(m string, im []Image) TurnResult {
31
+ prompted = append(prompted, promptCall{m, im})
32
+ return TurnResult{Reason: StopEndTurn}
33
+ }}
34
+ }}
35
+ res, err := a.Prompt(promptText("stored-session", "hello again"))
36
+ if err != nil || res.StopReason != StopEndTurn {
37
+ t.Fatalf("res=%v err=%v", res, err)
38
+ }
39
+ if !reflect.DeepEqual(spawns, []SpawnParams{{Cwd: "/tmp/store-project", SessionPath: "/tmp/store-project/session.jsonl"}}) {
40
+ t.Errorf("spawns = %+v", spawns)
41
+ }
42
+ if len(prompted) != 1 || prompted[0].Message != "hello again" || len(prompted[0].Images) != 0 {
43
+ t.Errorf("prompted = %+v", prompted)
44
+ }
45
+ want := StoredSession{SessionID: "stored-session", Cwd: "/tmp/store-project", SessionFile: "/tmp/store-project/session.jsonl"}
46
+ if len(store.upserts) != 1 || store.upserts[0].SessionID != want.SessionID || store.upserts[0].Cwd != want.Cwd || store.upserts[0].SessionFile != want.SessionFile {
47
+ t.Errorf("upserts = %+v", store.upserts)
48
+ }
49
+ })
50
+
51
+ tw(t, "unit/session-restore", "PiAcpAgent: setSessionConfigOption auto-restores via pi session discovery when SessionStore misses", func(t *testing.T) {
52
+ conn := newFakeConn()
53
+ root := t.TempDir()
54
+ sessionFile := filepath.Join(root, "sessions", "--tmp--fallback-project--", "0000_restore_fallback.jsonl")
55
+ writeSessionFile(t, sessionFile, headerLine("fallback-session", "/tmp/fallback-project"))
56
+ withAgentDir(t, root)
57
+ state := map[string]any{"thinkingLevel": "medium", "model": map[string]any{"provider": "test", "id": "alpha"}}
58
+ var setModel []map[string]string
59
+ var spawns []SpawnParams
60
+ proc := newFakeProc()
61
+ proc.getLevelsFn = func() ([]string, error) { return []string{"medium"}, nil }
62
+ proc.getModelsFn = func() (map[string]any, error) {
63
+ return models([3]string{"test", "alpha", "Alpha"}, [3]string{"test", "beta", "Beta"}), nil
64
+ }
65
+ proc.getStateFn = func() (map[string]any, error) { return state, nil }
66
+ proc.setModelFn = func(p, id string) error {
67
+ setModel = append(setModel, map[string]string{"provider": p, "modelId": id})
68
+ state["model"] = map[string]any{"provider": p, "id": id}
69
+ return nil
70
+ }
71
+ store := newMemStore()
72
+ a, _ := testAgent(conn)
73
+ a.store = store
74
+ a.spawn = func(p SpawnParams) (Proc, error) { spawns = append(spawns, p); return proc, nil }
75
+ a.sessions = &fakeSessions{build: func(id string, p SessionCreateParams) ActiveSession {
76
+ return &fakeSession{id: id, cwd: p.Cwd, proc: p.Proc}
77
+ }}
78
+ result, err := a.SetSessionConfigOption(SetSessionConfigOptionRequest{SessionID: "fallback-session", ConfigID: "model", Value: "test/beta"})
79
+ if err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ if !reflect.DeepEqual(spawns, []SpawnParams{{Cwd: "/tmp/fallback-project", SessionPath: sessionFile}}) {
83
+ t.Errorf("spawns = %+v", spawns)
84
+ }
85
+ jsonEqual(t, setModel, []any{map[string]any{"provider": "test", "modelId": "beta"}})
86
+ if optionValue(result.ConfigOptions, "model") != "test/beta" {
87
+ t.Errorf("model = %q", optionValue(result.ConfigOptions, "model"))
88
+ }
89
+ if len(store.upserts) != 2 {
90
+ t.Fatalf("upserts = %+v", store.upserts)
91
+ }
92
+ for _, u := range store.upserts {
93
+ if u.SessionID != "fallback-session" || u.Cwd != "/tmp/fallback-project" || u.SessionFile != sessionFile {
94
+ t.Errorf("upsert = %+v", u)
95
+ }
96
+ }
97
+ var got []Update
98
+ for _, u := range conn.all() {
99
+ got = append(got, u.Update)
100
+ }
101
+ jsonEqual(t, got, []any{
102
+ map[string]any{"sessionUpdate": "current_mode_update", "currentModeId": "medium"},
103
+ map[string]any{"sessionUpdate": "config_option_update", "configOptions": result.ConfigOptions},
104
+ })
105
+ })
106
+
107
+ tw(t, "unit/session-restore", "PiAcpAgent: cancel ignores stale session IDs without spawning a restore process", func(t *testing.T) {
108
+ conn := newFakeConn()
109
+ var spawns []SpawnParams
110
+ a, _ := testAgent(conn)
111
+ a.spawn = func(p SpawnParams) (Proc, error) { spawns = append(spawns, p); return newFakeProc(), nil }
112
+ a.sessions = &fakeSessions{build: func(string, SessionCreateParams) ActiveSession {
113
+ t.Error("cancel should not restore a missing session")
114
+ return nil
115
+ }}
116
+ if err := a.Cancel("stale-session"); err != nil {
117
+ t.Fatal(err)
118
+ }
119
+ if len(spawns) != 0 || len(conn.all()) != 0 {
120
+ t.Errorf("spawns=%v updates=%v", spawns, conn.all())
121
+ }
122
+ })
123
+ }
124
+
125
+ func TestSessionDelete(t *testing.T) {
126
+ deleteAgent := func(store *memStore) *Agent {
127
+ a, _ := testAgent(newFakeConn())
128
+ a.store = store
129
+ return a
130
+ }
131
+ tw(t, "unit/session-delete", "PiAcpAgent: deleteSession removes stored session and session file", func(t *testing.T) {
132
+ root := t.TempDir()
133
+ sessionFile := filepath.Join(root, "sessions", "--tmp--delete-project--", "0000_delete_me.jsonl")
134
+ writeSessionFile(t, sessionFile, headerLine("sess-del-store", "/tmp/delete-project"))
135
+ withAgentDir(t, root)
136
+ store := newMemStore(StoredSession{SessionID: "stored-session", Cwd: "/tmp/delete-project", SessionFile: sessionFile})
137
+ res, err := deleteAgent(store).DeleteSession(DeleteSessionRequest{SessionID: "stored-session"})
138
+ if err != nil || len(res) != 0 {
139
+ t.Fatalf("res=%v err=%v", res, err)
140
+ }
141
+ if !reflect.DeepEqual(store.deletes, []string{"stored-session"}) {
142
+ t.Errorf("deletes = %v", store.deletes)
143
+ }
144
+ if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
145
+ t.Error("session file still exists")
146
+ }
147
+ })
148
+ tw(t, "unit/session-delete", "PiAcpAgent: deleteSession finds session via pi discovery when SessionStore misses", func(t *testing.T) {
149
+ root := t.TempDir()
150
+ sessionFile := filepath.Join(root, "sessions", "--tmp--delete-discovery--", "0000_pi_discovery.jsonl")
151
+ writeSessionFile(t, sessionFile, headerLine("pi-discovered-session", "/tmp/delete-discovery"))
152
+ withAgentDir(t, root)
153
+ store := newMemStore()
154
+ res, err := deleteAgent(store).DeleteSession(DeleteSessionRequest{SessionID: "pi-discovered-session"})
155
+ if err != nil || len(res) != 0 {
156
+ t.Fatalf("res=%v err=%v", res, err)
157
+ }
158
+ if !reflect.DeepEqual(store.deletes, []string{"pi-discovered-session"}) {
159
+ t.Errorf("deletes = %v", store.deletes)
160
+ }
161
+ if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
162
+ t.Error("session file still exists")
163
+ }
164
+ })
165
+ tw(t, "unit/session-delete", "PiAcpAgent: deleteSession succeeds idempotently for unknown sessionId", func(t *testing.T) {
166
+ root := t.TempDir()
167
+ if err := os.MkdirAll(filepath.Join(root, "sessions", "--tmp--delete-unknown--"), 0o755); err != nil {
168
+ t.Fatal(err)
169
+ }
170
+ withAgentDir(t, root)
171
+ store := newMemStore()
172
+ res, err := deleteAgent(store).DeleteSession(DeleteSessionRequest{SessionID: "non-existent-session"})
173
+ if err != nil || len(res) != 0 || len(store.deletes) != 0 {
174
+ t.Fatalf("res=%v err=%v deletes=%v", res, err, store.deletes)
175
+ }
176
+ })
177
+ tw(t, "unit/session-delete", "PiAcpAgent: deleteSession survives missing session file", func(t *testing.T) {
178
+ root := t.TempDir()
179
+ missing := filepath.Join(root, "sessions", "--tmp--delete-missingfile--", "0000_non_existent.jsonl")
180
+ if err := os.MkdirAll(filepath.Dir(missing), 0o755); err != nil {
181
+ t.Fatal(err)
182
+ }
183
+ withAgentDir(t, root)
184
+ store := newMemStore(StoredSession{SessionID: "missing-file-session", Cwd: "/tmp/delete-missingfile", SessionFile: missing})
185
+ res, err := deleteAgent(store).DeleteSession(DeleteSessionRequest{SessionID: "missing-file-session"})
186
+ if err != nil || len(res) != 0 {
187
+ t.Fatalf("res=%v err=%v", res, err)
188
+ }
189
+ if !reflect.DeepEqual(store.deletes, []string{"missing-file-session"}) {
190
+ t.Errorf("deletes = %v", store.deletes)
191
+ }
192
+ })
193
+ }
194
+
195
+ func TestNewSessionFailures(t *testing.T) {
196
+ failing := func(models func() (map[string]any, error), state map[string]any) *fakeProc {
197
+ p := newFakeProc()
198
+ p.getModelsFn = models
199
+ p.getStateFn = func() (map[string]any, error) { return state, nil }
200
+ return p
201
+ }
202
+ errCode := func(err error) int {
203
+ if re, ok := err.(*RequestError); ok {
204
+ return re.Code
205
+ }
206
+ return 0
207
+ }
208
+
209
+ tw(t, "unit/new-session-auth-required-when-no-models", "PiAcpAgent: newSession throws AUTH_REQUIRED when pi reports zero available models", func(t *testing.T) {
210
+ proc := failing(func() (map[string]any, error) { return map[string]any{"models": []any{}}, nil }, map[string]any{"thinkingLevel": "medium", "model": nil})
211
+ sessions := &fakeSessions{session: &fakeSession{id: "s1", cwd: cwdNow(t), proc: proc}}
212
+ a, _ := testAgent(newFakeConn())
213
+ a.sessions = sessions
214
+ _, err := a.NewSession(newSessionReq(cwdNow(t)))
215
+ if errCode(err) != -32000 || !strings.Contains(strings.ToLower(err.Error()), "configure an api key or log in with an oauth provider") {
216
+ t.Fatalf("err = %v", err)
217
+ }
218
+ if !reflect.DeepEqual(sessions.closeCalls, []string{"s1"}) {
219
+ t.Errorf("closeCalls = %v", sessions.closeCalls)
220
+ }
221
+ })
222
+
223
+ tw(t, "unit/new-session-pi-not-found", "PiAcpAgent: newSession returns a helpful Internal error when pi is not installed", func(t *testing.T) {
224
+ t.Setenv("PI_ACP_PI_COMMAND", "pi-does-not-exist-12345")
225
+ a, _ := testAgent(newFakeConn())
226
+ a.sessions = NewSessionManager(nil, newMemStore())
227
+ _, err := a.NewSession(newSessionReq(cwdNow(t)))
228
+ if errCode(err) != -32603 || !strings.Contains(strings.ToLower(err.Error()), "executable not found") {
229
+ t.Fatalf("err = %v", err)
230
+ }
231
+ })
232
+
233
+ tw(t, "unit/new-session-runtime-startup-errors", "PiAcpAgent: newSession returns AUTH_REQUIRED when pi reports an auth error after spawn", func(t *testing.T) {
234
+ root := t.TempDir()
235
+ sessionFile := filepath.Join(root, "sessions", "failed.jsonl")
236
+ writeSessionFile(t, sessionFile, headerLine("s-auth", cwdNow(t)))
237
+ proc := failing(func() (map[string]any, error) { return nil, errors.New("Authentication required: missing key") },
238
+ map[string]any{"thinkingLevel": "medium", "model": nil, "sessionFile": sessionFile})
239
+ sessions := &fakeSessions{session: &fakeSession{id: "s-auth", cwd: cwdNow(t), proc: proc}}
240
+ store := NewFileStore(filepath.Join(root, "session-map.json"))
241
+ store.Upsert(StoredSession{SessionID: "s-auth", Cwd: cwdNow(t), SessionFile: sessionFile})
242
+ a, _ := testAgent(newFakeConn())
243
+ a.sessions, a.store = sessions, store
244
+ _, err := a.NewSession(newSessionReq(cwdNow(t)))
245
+ if errCode(err) != -32000 {
246
+ t.Fatalf("err = %v", err)
247
+ }
248
+ if !reflect.DeepEqual(sessions.closeCalls, []string{"s-auth"}) {
249
+ t.Errorf("closeCalls = %v", sessions.closeCalls)
250
+ }
251
+ if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
252
+ t.Error("the failed session file was not removed")
253
+ }
254
+ if store.Get("s-auth") != nil {
255
+ t.Error("the failed session is still in the store")
256
+ }
257
+ })
258
+
259
+ tw(t, "unit/new-session-runtime-startup-errors", "PiAcpAgent: newSession returns Internal error on non-auth model probe failures after spawn", func(t *testing.T) {
260
+ proc := failing(func() (map[string]any, error) { return nil, errors.New("socket hang up") }, map[string]any{"thinkingLevel": "medium", "model": nil})
261
+ sessions := &fakeSessions{session: &fakeSession{id: "s-internal", cwd: cwdNow(t), proc: proc}}
262
+ a, _ := testAgent(newFakeConn())
263
+ a.sessions = sessions
264
+ _, err := a.NewSession(newSessionReq(cwdNow(t)))
265
+ if errCode(err) != -32603 || !strings.Contains(err.Error(), "socket hang up") {
266
+ t.Fatalf("err = %v", err)
267
+ }
268
+ if !reflect.DeepEqual(sessions.closeCalls, []string{"s-internal"}) {
269
+ t.Errorf("closeCalls = %v", sessions.closeCalls)
270
+ }
271
+ })
272
+
273
+ for _, failure := range []string{"discovery", "auth", "invalid-current", "inconsistent-current", "state"} {
274
+ tw(t, "unit/new-session-runtime-startup-errors", "PiAcpAgent: cleans up only the new session after ${failure} configuration failure", func(t *testing.T) {
275
+ t.Run(fmt.Sprintf("PiAcpAgent: cleans up only the new session after %s configuration failure", failure), func(t *testing.T) {
276
+ root := t.TempDir()
277
+ sessionFile, existingFile := filepath.Join(root, "failed.jsonl"), filepath.Join(root, "existing.jsonl")
278
+ write(t, sessionFile, "new session\n")
279
+ write(t, existingFile, "existing session\n")
280
+ store := NewFileStore(filepath.Join(root, "map.json"))
281
+ store.Upsert(StoredSession{SessionID: "failed", Cwd: root, SessionFile: sessionFile})
282
+ store.Upsert(StoredSession{SessionID: "existing", Cwd: root, SessionFile: existingFile})
283
+ proc := newFakeProc()
284
+ proc.getModelsFn = func() (map[string]any, error) { return models([3]string{"test", "model", ""}), nil }
285
+ proc.getStateFn = func() (map[string]any, error) {
286
+ if failure == "state" {
287
+ return nil, errors.New("state read failed")
288
+ }
289
+ level := "max"
290
+ if failure == "invalid-current" {
291
+ level = ""
292
+ } else if failure == "inconsistent-current" {
293
+ level = "medium"
294
+ }
295
+ return map[string]any{"sessionFile": sessionFile, "thinkingLevel": level}, nil
296
+ }
297
+ proc.getLevelsFn = func() ([]string, error) {
298
+ if failure == "discovery" {
299
+ return nil, errors.New("discovery failed")
300
+ }
301
+ if failure == "auth" {
302
+ return nil, errors.New("Authentication required: missing key")
303
+ }
304
+ return []string{"low", "high", "max"}, nil
305
+ }
306
+ sessions := &fakeSessions{session: &fakeSession{id: "failed", cwd: root, proc: proc}}
307
+ conn := newFakeConn()
308
+ a, _ := testAgent(conn)
309
+ a.sessions, a.store = sessions, store
310
+ _, err := a.NewSession(newSessionReq(root))
311
+ want := -32603
312
+ if failure == "auth" {
313
+ want = -32000
314
+ }
315
+ if errCode(err) != want {
316
+ t.Fatalf("err = %v (code %d), want code %d", err, errCode(err), want)
317
+ }
318
+ if !reflect.DeepEqual(sessions.closeCalls, []string{"failed"}) {
319
+ t.Errorf("closeCalls = %v", sessions.closeCalls)
320
+ }
321
+ if _, err := os.Stat(sessionFile); !os.IsNotExist(err) {
322
+ t.Error("the failed session file was not removed")
323
+ }
324
+ if store.Get("failed") != nil {
325
+ t.Error("failed still in the store")
326
+ }
327
+ if _, err := os.Stat(existingFile); err != nil {
328
+ t.Error("the existing session file was removed")
329
+ }
330
+ if e := store.Get("existing"); e == nil || e.SessionFile != existingFile {
331
+ t.Errorf("existing = %+v", e)
332
+ }
333
+ if len(conn.all()) != 0 {
334
+ t.Error("updates sent for a failed session")
335
+ }
336
+ })
337
+ })
338
+ }
339
+ }
340
+
341
+ func TestLoadConfigurationFailure(t *testing.T) {
342
+ for _, failure := range []string{"discovery", "missing-current", "inconsistent-current", "state"} {
343
+ tw(t, "unit/model-thinking-levels", "load configuration ${failure} failure closes restored child and preserves history for retry", func(t *testing.T) {
344
+ t.Run(fmt.Sprintf("load configuration %s failure closes restored child and preserves history for retry", failure), func(t *testing.T) {
345
+ root := t.TempDir()
346
+ sessionFile := filepath.Join(root, "history.jsonl")
347
+ write(t, sessionFile, "persisted history\n")
348
+ store := NewFileStore(filepath.Join(root, "map.json"))
349
+ store.Upsert(StoredSession{SessionID: "s1", Cwd: root, SessionFile: sessionFile})
350
+ f := newLevelsFixture(t)
351
+ conn := f.conn
352
+ sch := f.sch
353
+ mgr := NewSessionManager(nil, store)
354
+ f.agent.sessions, f.agent.store = mgr, store
355
+ existing := newFakeProc()
356
+ mgr.GetOrCreate("existing", SessionCreateParams{Cwd: root, McpServers: []any{}, Conn: conn, Proc: existing})
357
+ defer mgr.DisposeAll()
358
+ restoredDisposed, historyReads, spawns := 0, 0, 0
359
+ shouldFail := true
360
+ f.agent.spawn = func(SpawnParams) (Proc, error) {
361
+ spawns++
362
+ p := newFakeProc()
363
+ p.getModelsFn = f.proc.getModelsFn
364
+ p.getStateFn = func() (map[string]any, error) {
365
+ if shouldFail && failure == "state" {
366
+ return nil, errors.New("state unavailable")
367
+ }
368
+ if shouldFail && failure == "missing-current" {
369
+ return map[string]any{}, nil
370
+ }
371
+ if shouldFail && failure == "inconsistent-current" {
372
+ return map[string]any{"thinkingLevel": "medium"}, nil
373
+ }
374
+ return f.proc.GetState()
375
+ }
376
+ p.getLevelsFn = func() ([]string, error) {
377
+ if shouldFail && failure == "discovery" {
378
+ return nil, errors.New("discovery unavailable")
379
+ }
380
+ return f.proc.GetAvailableThinkingLevels()
381
+ }
382
+ p.getMessagesFn = func() (map[string]any, error) {
383
+ historyReads++
384
+ return map[string]any{"messages": []any{map[string]any{"role": "user", "content": "persisted prompt"}}}, nil
385
+ }
386
+ p.disposeHook = func() { restoredDisposed++ }
387
+ return p, nil
388
+ }
389
+ mgr.spawn = f.agent.spawn
390
+ if _, err := f.agent.LoadSession(LoadSessionRequest{SessionID: "s1", Cwd: root, McpServers: []any{}}); err == nil {
391
+ t.Fatal("expected the load to fail")
392
+ }
393
+ if restoredDisposed != 1 {
394
+ t.Errorf("restored child disposed %d times", restoredDisposed)
395
+ }
396
+ if mgr.MaybeGet("s1") != nil {
397
+ t.Error("s1 is still registered")
398
+ }
399
+ if mgr.MaybeGet("existing") == nil || existing.disposeCount() != 0 {
400
+ t.Error("the existing session was disturbed")
401
+ }
402
+ if historyReads != 0 || len(conn.all()) != 0 {
403
+ t.Errorf("historyReads=%d updates=%d", historyReads, len(conn.all()))
404
+ }
405
+ if b, _ := os.ReadFile(sessionFile); string(b) != "persisted history\n" {
406
+ t.Errorf("history file = %q", b)
407
+ }
408
+ if e := store.Get("s1"); e == nil || e.SessionFile != sessionFile {
409
+ t.Errorf("store entry = %+v", e)
410
+ }
411
+ shouldFail = false
412
+ r, err := f.agent.LoadSession(LoadSessionRequest{SessionID: "s1", Cwd: root, McpServers: []any{}})
413
+ if err != nil {
414
+ t.Fatal(err)
415
+ }
416
+ if r.Modes.CurrentModeID != "max" || spawns != 2 || restoredDisposed != 1 || historyReads != 1 {
417
+ t.Errorf("mode=%q spawns=%d disposed=%d reads=%d", r.Modes.CurrentModeID, spawns, restoredDisposed, historyReads)
418
+ }
419
+ if len(conn.ofKind("user_message_chunk")) == 0 {
420
+ t.Error("history was not replayed on the retry")
421
+ }
422
+ if b, _ := os.ReadFile(sessionFile); string(b) != "persisted history\n" {
423
+ t.Errorf("history file = %q", b)
424
+ }
425
+ sch.drain()
426
+ })
427
+ })
428
+ }
429
+ }
@@ -0,0 +1,61 @@
1
+ package acp
2
+
3
+ import (
4
+ "os"
5
+ "strings"
6
+ )
7
+
8
+ // PiSetupMethodID is the id of the terminal login method.
9
+ const PiSetupMethodID = "pi_terminal_login"
10
+
11
+ // TerminalLoginArgs are the arguments that make this executable start pig interactively. main
12
+ // adds --pig (and friends) so the login uses the same pig the adapter drives.
13
+ var TerminalLoginArgs = []string{"--terminal-login"}
14
+
15
+ // AuthMethods returns the advertised auth methods. Zed renders a banner from
16
+ // _meta["terminal-auth"]; the type/args/env shape is what registries require. Both are sent.
17
+ func AuthMethods(supportsTerminalAuthMeta bool) []map[string]any {
18
+ args := append([]any{}, anySlice(TerminalLoginArgs)...)
19
+ method := map[string]any{
20
+ "id": PiSetupMethodID,
21
+ "name": "Launch pig in the terminal",
22
+ "description": "Start pig in an interactive terminal to configure API keys or login",
23
+ "type": "terminal",
24
+ "args": args,
25
+ "env": map[string]any{},
26
+ }
27
+ if supportsTerminalAuthMeta {
28
+ cmd := "pig-acp"
29
+ if exe, err := os.Executable(); err == nil && exe != "" {
30
+ cmd = exe
31
+ }
32
+ method["_meta"] = map[string]any{"terminal-auth": map[string]any{
33
+ "command": cmd, "args": append([]any{}, anySlice(TerminalLoginArgs)...), "label": "Launch pig",
34
+ }}
35
+ }
36
+ return []map[string]any{method}
37
+ }
38
+
39
+ func anySlice(s []string) []any {
40
+ out := make([]any, len(s))
41
+ for i, v := range s {
42
+ out[i] = v
43
+ }
44
+ return out
45
+ }
46
+
47
+ var authPatterns = []string{"api key", "apikey", "missing key", "no key", "not configured", "unauthorized", "authentication", "permission denied", "forbidden", "401", "403"}
48
+
49
+ // MaybeAuthRequiredError turns a missing-credentials message into an AUTH_REQUIRED error.
50
+ func MaybeAuthRequiredError(err error) *RequestError {
51
+ if err == nil {
52
+ return nil
53
+ }
54
+ s := strings.ToLower(err.Error())
55
+ for _, p := range authPatterns {
56
+ if strings.Contains(s, p) {
57
+ return ErrAuthRequired(map[string]any{"authMethods": AuthMethods(true)}, "Configure an API key or log in with an OAuth provider.")
58
+ }
59
+ }
60
+ return nil
61
+ }
@@ -0,0 +1,47 @@
1
+ package acp
2
+
3
+ import (
4
+ "strconv"
5
+ "strings"
6
+ "testing"
7
+ )
8
+
9
+ // pig-acp translates pi's events into ACP updates. Pi sends the whole accumulated bash output with every update,
10
+ // so the translation is O(output) per update by the input's own shape; this measures that cost, per update.
11
+ //
12
+ // go test -run xxx -bench . -benchmem
13
+ func BenchmarkBashOutputUpdate(b *testing.B) {
14
+ for _, size := range []int{4 << 10, 256 << 10} {
15
+ prev := strings.Repeat("compiling package line of output\n", size/33)
16
+ result := map[string]any{"content": []any{map[string]any{"type": "text", "text": prev + "one more line\n"}}}
17
+ b.Run(strconv.Itoa(size>>10)+"KiB", func(b *testing.B) {
18
+ b.SetBytes(int64(size))
19
+ b.ReportAllocs()
20
+ for i := 0; i < b.N; i++ {
21
+ text := BashResultText(result)
22
+ delta := BashOutputDelta(prev, text)
23
+ _ = BashTerminalOutputMeta("call1", delta)
24
+ }
25
+ })
26
+ }
27
+ }
28
+
29
+ func BenchmarkPromptToPiMessage(b *testing.B) {
30
+ blocks := []ContentBlock{
31
+ {"type": "text", "text": "Please look at this file and fix the failing test."},
32
+ {"type": "resource_link", "uri": "file:///work/repo/internal/module/file.go", "name": "file.go"},
33
+ {"type": "text", "text": strings.Repeat("More context about the change. ", 40)},
34
+ }
35
+ b.ReportAllocs()
36
+ for i := 0; i < b.N; i++ {
37
+ PromptToPiMessage(blocks)
38
+ }
39
+ }
40
+
41
+ func BenchmarkToolResultToText(b *testing.B) {
42
+ result := map[string]any{"content": []any{map[string]any{"type": "text", "text": strings.Repeat("file contents line\n", 200)}}, "details": map[string]any{"exitCode": 0}}
43
+ b.ReportAllocs()
44
+ for i := 0; i < b.N; i++ {
45
+ ToolResultToText(result)
46
+ }
47
+ }