@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,352 @@
1
+ package acp
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "strings"
8
+ "unicode"
9
+ )
10
+
11
+ // This file ports src/acp/translate/{prompt,pi-tools,pi-messages,bash}.ts. Values that come
12
+ // from pi are decoded JSON (map[string]any, []any, string, float64), read the way the original
13
+ // reads them: a wrong type reads as absent, never as an error.
14
+
15
+ // jsTrim is String.prototype.trim: Unicode white space plus the byte order mark.
16
+ func jsTrim(s string) string {
17
+ return strings.TrimFunc(s, func(r rune) bool { return unicode.IsSpace(r) || r == '\uFEFF' })
18
+ }
19
+
20
+ func asObject(v any) map[string]any {
21
+ m, _ := v.(map[string]any)
22
+ return m
23
+ }
24
+
25
+ func strOf(m map[string]any, key string) (string, bool) {
26
+ s, ok := m[key].(string)
27
+ return s, ok
28
+ }
29
+
30
+ // base64ByteLength is Buffer.byteLength(s, 'base64').
31
+ func base64ByteLength(s string) int {
32
+ n := len(s)
33
+ if n == 0 {
34
+ return 0
35
+ }
36
+ pad := 0
37
+ if strings.HasSuffix(s, "==") {
38
+ pad = 2
39
+ } else if strings.HasSuffix(s, "=") {
40
+ pad = 1
41
+ }
42
+ return (n*3)>>2 - pad
43
+ }
44
+
45
+ // PromptToPiMessage turns ACP prompt blocks into pi message text and images.
46
+ func PromptToPiMessage(blocks []ContentBlock) (string, []Image) {
47
+ var msg strings.Builder
48
+ images := []Image{}
49
+ for _, b := range blocks {
50
+ switch b["type"] {
51
+ case "text":
52
+ if s, ok := b["text"].(string); ok {
53
+ msg.WriteString(s)
54
+ }
55
+ case "resource_link":
56
+ fmt.Fprintf(&msg, "\n[Context] %s", jsString(b["uri"]))
57
+ case "image":
58
+ // pi expects base64 image bytes in data without a data-url prefix.
59
+ images = append(images, Image{"type": "image", "mimeType": b["mimeType"], "data": b["data"]})
60
+ case "resource":
61
+ r := asObject(b["resource"])
62
+ uri := "(unknown)"
63
+ if s, ok := strOf(r, "uri"); ok {
64
+ uri = s
65
+ }
66
+ if text, ok := strOf(r, "text"); ok {
67
+ mime := "text/plain"
68
+ if s, ok := strOf(r, "mimeType"); ok {
69
+ mime = s
70
+ }
71
+ fmt.Fprintf(&msg, "\n[Embedded Context] %s (%s)\n%s", uri, mime, text)
72
+ } else if blob, ok := strOf(r, "blob"); ok {
73
+ mime := "application/octet-stream"
74
+ if s, ok := strOf(r, "mimeType"); ok {
75
+ mime = s
76
+ }
77
+ fmt.Fprintf(&msg, "\n[Embedded Context] %s (%s, %d bytes)", uri, mime, base64ByteLength(blob))
78
+ } else {
79
+ fmt.Fprintf(&msg, "\n[Embedded Context] %s", uri)
80
+ }
81
+ case "audio":
82
+ data, _ := b["data"].(string)
83
+ fmt.Fprintf(&msg, "\n[Audio] (%s, %d bytes) not supported by pig-acp", jsString(b["mimeType"]), base64ByteLength(data))
84
+ }
85
+ }
86
+ return msg.String(), images
87
+ }
88
+
89
+ // jsString renders a value like a JS template literal would.
90
+ func jsString(v any) string {
91
+ switch x := v.(type) {
92
+ case nil:
93
+ return "undefined"
94
+ case string:
95
+ return x
96
+ case float64:
97
+ return fmt.Sprint(x)
98
+ default:
99
+ return fmt.Sprint(x)
100
+ }
101
+ }
102
+
103
+ func textBlocks(content any) string {
104
+ arr, ok := content.([]any)
105
+ if !ok {
106
+ return ""
107
+ }
108
+ var b strings.Builder
109
+ for _, c := range arr {
110
+ m := asObject(c)
111
+ if m["type"] == "text" {
112
+ if s, ok := m["text"].(string); ok {
113
+ b.WriteString(s)
114
+ }
115
+ }
116
+ }
117
+ return b.String()
118
+ }
119
+
120
+ // NormalizePiMessageText joins the text of a user message.
121
+ func NormalizePiMessageText(content any) string {
122
+ if s, ok := content.(string); ok {
123
+ return s
124
+ }
125
+ return textBlocks(content)
126
+ }
127
+
128
+ // NormalizePiAssistantText joins the text blocks of an assistant message.
129
+ func NormalizePiAssistantText(content any) string { return textBlocks(content) }
130
+
131
+ func firstString(pairs ...[2]any) (string, bool) {
132
+ for _, p := range pairs {
133
+ if m, ok := p[0].(map[string]any); ok {
134
+ if s, ok := m[p[1].(string)].(string); ok {
135
+ return s, true
136
+ }
137
+ }
138
+ }
139
+ return "", false
140
+ }
141
+
142
+ // asNumber reads a JSON number (float64 after decoding; Go integers when built by hand).
143
+ func asNumber(v any) (float64, bool) {
144
+ switch n := v.(type) {
145
+ case float64:
146
+ return n, true
147
+ case float32:
148
+ return float64(n), true
149
+ case int:
150
+ return float64(n), true
151
+ case int64:
152
+ return float64(n), true
153
+ case int32:
154
+ return float64(n), true
155
+ }
156
+ return 0, false
157
+ }
158
+
159
+ func firstNumber(pairs ...[2]any) (float64, bool) {
160
+ for _, p := range pairs {
161
+ if m, ok := p[0].(map[string]any); ok {
162
+ if n, ok := asNumber(m[p[1].(string)]); ok {
163
+ return n, true
164
+ }
165
+ }
166
+ }
167
+ return 0, false
168
+ }
169
+
170
+ // jsonStringify is JSON.stringify(v, null, 2) apart from key order (Go sorts map keys).
171
+ func jsonStringify(v any) string {
172
+ var buf bytes.Buffer
173
+ enc := json.NewEncoder(&buf)
174
+ enc.SetEscapeHTML(false)
175
+ enc.SetIndent("", " ")
176
+ if err := enc.Encode(v); err != nil {
177
+ return fmt.Sprint(v)
178
+ }
179
+ return strings.TrimSuffix(buf.String(), "\n")
180
+ }
181
+
182
+ // truthy is JS truthiness for the values a tool result can hold.
183
+ func truthy(v any) bool {
184
+ switch x := v.(type) {
185
+ case nil:
186
+ return false
187
+ case bool:
188
+ return x
189
+ case string:
190
+ return x != ""
191
+ case float64:
192
+ return x != 0
193
+ }
194
+ return true
195
+ }
196
+
197
+ // ToolResultToText renders a pi tool result as text.
198
+ func ToolResultToText(result any) string {
199
+ if !truthy(result) {
200
+ return ""
201
+ }
202
+ res := asObject(result)
203
+ details := asObject(res["details"])
204
+
205
+ // pi's edit tool returns a terse success message in content and the full unified diff in details.diff.
206
+ if diff, ok := details["diff"].(string); ok && jsTrim(diff) != "" {
207
+ return diff
208
+ }
209
+ if content, ok := res["content"].([]any); ok {
210
+ var texts []string
211
+ for _, c := range content {
212
+ m := asObject(c)
213
+ if s, ok := m["text"].(string); ok && m["type"] == "text" && s != "" {
214
+ texts = append(texts, s)
215
+ }
216
+ }
217
+ if len(texts) > 0 {
218
+ return strings.Join(texts, "")
219
+ }
220
+ }
221
+
222
+ stdout, hasOut := firstString([2]any{details, "stdout"}, [2]any{res, "stdout"}, [2]any{details, "output"}, [2]any{res, "output"})
223
+ stderr, hasErr := firstString([2]any{details, "stderr"}, [2]any{res, "stderr"})
224
+ exit, hasExit := firstNumber([2]any{details, "exitCode"}, [2]any{res, "exitCode"}, [2]any{details, "code"}, [2]any{res, "code"})
225
+
226
+ if (hasOut && jsTrim(stdout) != "") || (hasErr && jsTrim(stderr) != "") {
227
+ var parts []string
228
+ if hasOut && jsTrim(stdout) != "" {
229
+ parts = append(parts, stdout)
230
+ }
231
+ if hasErr && jsTrim(stderr) != "" {
232
+ parts = append(parts, "stderr:\n"+stderr)
233
+ }
234
+ if hasExit {
235
+ parts = append(parts, fmt.Sprintf("exit code: %s", jsNumber(exit)))
236
+ }
237
+ return strings.TrimRightFunc(strings.Join(parts, "\n\n"), func(r rune) bool { return unicode.IsSpace(r) || r == '\uFEFF' })
238
+ }
239
+ return jsonStringify(result)
240
+ }
241
+
242
+ func jsNumber(f float64) string {
243
+ if f == float64(int64(f)) {
244
+ return fmt.Sprint(int64(f))
245
+ }
246
+ return fmt.Sprint(f)
247
+ }
248
+
249
+ // IsBashTool reports whether a tool name is bash (case-insensitive).
250
+ func IsBashTool(name string) bool { return strings.ToLower(name) == "bash" }
251
+
252
+ // BashCommand finds the command in tool arguments or a result.
253
+ func BashCommand(v any) (string, bool) {
254
+ rec := asObject(v)
255
+ get := func(m map[string]any) (any, bool) {
256
+ if m == nil {
257
+ return nil, false
258
+ }
259
+ for _, k := range []string{"command", "cmd"} {
260
+ if x, ok := m[k]; ok && x != nil {
261
+ return x, true
262
+ }
263
+ }
264
+ return nil, false
265
+ }
266
+ // ?? picks the first non-nullish value even when it is not a string.
267
+ for _, m := range []map[string]any{rec, asObject(rec["args"]), asObject(rec["input"]), asObject(rec["rawInput"]), asObject(rec["toolInput"]), asObject(rec["details"])} {
268
+ if x, ok := get(m); ok {
269
+ if s, ok := x.(string); ok && jsTrim(s) != "" {
270
+ return s, true
271
+ }
272
+ return "", false
273
+ }
274
+ }
275
+ return "", false
276
+ }
277
+
278
+ // BashResultText is the output text of a bash result.
279
+ func BashResultText(result any) string {
280
+ rec := asObject(result)
281
+ if content, ok := rec["content"].([]any); ok {
282
+ var texts []string
283
+ for _, c := range content {
284
+ m := asObject(c)
285
+ if s, ok := m["text"].(string); ok && m["type"] == "text" && s != "" {
286
+ texts = append(texts, s)
287
+ }
288
+ }
289
+ if len(texts) > 0 {
290
+ return strings.Join(texts, "")
291
+ }
292
+ }
293
+ details := asObject(rec["details"])
294
+ stdout, _ := firstString([2]any{details, "stdout"}, [2]any{rec, "stdout"}, [2]any{details, "output"}, [2]any{rec, "output"})
295
+ stderr, _ := firstString([2]any{details, "stderr"}, [2]any{rec, "stderr"})
296
+ var parts []string
297
+ for _, p := range []string{stdout, stderr} {
298
+ if p != "" {
299
+ parts = append(parts, p)
300
+ }
301
+ }
302
+ return strings.Join(parts, "\n")
303
+ }
304
+
305
+ // BashExitCode is the exit code of a bash result (1 for an error without one).
306
+ func BashExitCode(result any, isError bool) int {
307
+ rec := asObject(result)
308
+ details := asObject(rec["details"])
309
+ // details?.exitCode ?? record?.exitCode ?? details?.code ?? record?.code: first non-nullish.
310
+ for _, p := range [][2]any{{details, "exitCode"}, {rec, "exitCode"}, {details, "code"}, {rec, "code"}} {
311
+ if m, _ := p[0].(map[string]any); m != nil {
312
+ if v, ok := m[p[1].(string)]; ok && v != nil {
313
+ if n, ok := asNumber(v); ok {
314
+ return int(n)
315
+ }
316
+ break
317
+ }
318
+ }
319
+ }
320
+ if isError {
321
+ return 1
322
+ }
323
+ return 0
324
+ }
325
+
326
+ // BashOutputDelta is the appended part of the output.
327
+ func BashOutputDelta(previous, next string) string {
328
+ if strings.HasPrefix(next, previous) {
329
+ return next[len(previous):]
330
+ }
331
+ return next
332
+ }
333
+
334
+ // BashTerminalContent is the terminal tool-call content.
335
+ func BashTerminalContent(toolCallID string) []any {
336
+ return []any{map[string]any{"type": "terminal", "terminalId": toolCallID}}
337
+ }
338
+
339
+ // BashTerminalInfoMeta is the terminal_info _meta.
340
+ func BashTerminalInfoMeta(toolCallID, cwd string) map[string]any {
341
+ return map[string]any{"terminal_info": map[string]any{"terminal_id": toolCallID, "cwd": cwd}}
342
+ }
343
+
344
+ // BashTerminalOutputMeta is the terminal_output _meta.
345
+ func BashTerminalOutputMeta(toolCallID, data string) map[string]any {
346
+ return map[string]any{"terminal_output": map[string]any{"terminal_id": toolCallID, "data": data}}
347
+ }
348
+
349
+ // BashTerminalExitMeta is the terminal_exit _meta.
350
+ func BashTerminalExitMeta(toolCallID string, exitCode int) map[string]any {
351
+ return map[string]any{"terminal_exit": map[string]any{"terminal_id": toolCallID, "exit_code": exitCode, "signal": nil}}
352
+ }
@@ -0,0 +1,105 @@
1
+ package acp
2
+
3
+ // Twins of test/unit/pi-tools.test.ts, prompt-to-pi-message.test.ts, pi-messages.test.ts.
4
+
5
+ import (
6
+ "encoding/base64"
7
+ "regexp"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestPiTools(t *testing.T) {
13
+ tw(t, "unit/pi-tools", "toolResultToText: extracts text from content blocks", func(t *testing.T) {
14
+ got := ToolResultToText(map[string]any{"content": []any{
15
+ map[string]any{"type": "text", "text": "hello"}, map[string]any{"type": "text", "text": " world"}}})
16
+ if got != "hello world" {
17
+ t.Errorf("got %q", got)
18
+ }
19
+ })
20
+ tw(t, "unit/pi-tools", "toolResultToText: prefers details.diff when present", func(t *testing.T) {
21
+ got := ToolResultToText(map[string]any{
22
+ "content": []any{map[string]any{"type": "text", "text": "Successfully replaced 2 block(s) in a.txt."}},
23
+ "details": map[string]any{"diff": "--- a\n+++ b\n"}})
24
+ if got != "--- a\n+++ b\n" {
25
+ t.Errorf("got %q", got)
26
+ }
27
+ })
28
+ tw(t, "unit/pi-tools", "toolResultToText: falls back to JSON", func(t *testing.T) {
29
+ got := ToolResultToText(map[string]any{"a": 1})
30
+ if !regexp.MustCompile(`"a": 1`).MatchString(got) {
31
+ t.Errorf("got %q", got)
32
+ }
33
+ })
34
+ tw(t, "unit/pi-tools", "toolResultToText: extracts bash stdout/stderr from details", func(t *testing.T) {
35
+ got := ToolResultToText(map[string]any{"details": map[string]any{"stdout": "ok\n", "stderr": "warn\n", "exitCode": 0}})
36
+ for _, want := range []string{"ok", "stderr:", "warn", "exit code: 0"} {
37
+ if !strings.Contains(got, want) {
38
+ t.Errorf("%q missing %q", got, want)
39
+ }
40
+ }
41
+ })
42
+ }
43
+
44
+ func TestPromptToPiMessage(t *testing.T) {
45
+ tw(t, "unit/prompt-to-pi-message", "promptToPiMessage: concatenates text and resource links", func(t *testing.T) {
46
+ msg, images := PromptToPiMessage([]ContentBlock{
47
+ {"type": "text", "text": "Hello"},
48
+ {"type": "resource_link", "uri": "file:///tmp/foo.txt", "name": "foo"},
49
+ {"type": "text", "text": " world"}})
50
+ if msg != "Hello\n[Context] file:///tmp/foo.txt world" || len(images) != 0 {
51
+ t.Errorf("msg=%q images=%v", msg, images)
52
+ }
53
+ })
54
+ tw(t, "unit/prompt-to-pi-message", "promptToPiMessage: includes embedded resource text as marker", func(t *testing.T) {
55
+ msg, images := PromptToPiMessage([]ContentBlock{{"type": "resource", "resource": map[string]any{"uri": "file:///tmp/a.txt", "mimeType": "text/plain", "text": "hi"}}})
56
+ if msg != "\n[Embedded Context] file:///tmp/a.txt (text/plain)\nhi" || len(images) != 0 {
57
+ t.Errorf("msg=%q", msg)
58
+ }
59
+ })
60
+ tw(t, "unit/prompt-to-pi-message", "promptToPiMessage: includes embedded resource blob as marker", func(t *testing.T) {
61
+ blob := base64.StdEncoding.EncodeToString([]byte("xyz"))
62
+ msg, images := PromptToPiMessage([]ContentBlock{{"type": "resource", "resource": map[string]any{"uri": "file:///tmp/a.bin", "mimeType": "application/octet-stream", "blob": blob}}})
63
+ if msg != "\n[Embedded Context] file:///tmp/a.bin (application/octet-stream, 3 bytes)" || len(images) != 0 {
64
+ t.Errorf("msg=%q", msg)
65
+ }
66
+ })
67
+ tw(t, "unit/prompt-to-pi-message", "promptToPiMessage: includes audio as marker", func(t *testing.T) {
68
+ data := base64.StdEncoding.EncodeToString([]byte("abc"))
69
+ msg, images := PromptToPiMessage([]ContentBlock{{"type": "audio", "mimeType": "audio/wav", "data": data}})
70
+ // Renamed identity: the marker names pig-acp, where the original names pi-acp.
71
+ if msg != "\n[Audio] (audio/wav, 3 bytes) not supported by pig-acp" || len(images) != 0 {
72
+ t.Errorf("msg=%q", msg)
73
+ }
74
+ })
75
+ tw(t, "unit/prompt-to-pi-message", "promptToPiMessage: maps image to pi image content", func(t *testing.T) {
76
+ b64 := base64.StdEncoding.EncodeToString([]byte("abc"))
77
+ msg, images := PromptToPiMessage([]ContentBlock{{"type": "text", "text": "see"}, {"type": "image", "mimeType": "image/png", "data": b64, "uri": "img-1"}})
78
+ if msg != "see" || len(images) != 1 {
79
+ t.Fatalf("msg=%q images=%v", msg, images)
80
+ }
81
+ jsonEqual(t, images[0], map[string]any{"type": "image", "mimeType": "image/png", "data": b64})
82
+ })
83
+ }
84
+
85
+ func TestPiMessages(t *testing.T) {
86
+ tw(t, "unit/pi-messages", "normalizePiMessageText: supports string", func(t *testing.T) {
87
+ if NormalizePiMessageText("hello") != "hello" {
88
+ t.Fail()
89
+ }
90
+ })
91
+ tw(t, "unit/pi-messages", "normalizePiMessageText: joins text blocks", func(t *testing.T) {
92
+ got := NormalizePiMessageText([]any{
93
+ map[string]any{"type": "text", "text": "a"}, map[string]any{"type": "text", "text": "b"}, map[string]any{"type": "not_text", "x": 1}})
94
+ if got != "ab" {
95
+ t.Errorf("got %q", got)
96
+ }
97
+ })
98
+ tw(t, "unit/pi-messages", "normalizePiAssistantText: joins only text blocks", func(t *testing.T) {
99
+ got := NormalizePiAssistantText([]any{
100
+ map[string]any{"type": "text", "text": "hi"}, map[string]any{"type": "thinking", "text": "..."}, map[string]any{"type": "text", "text": "!"}})
101
+ if got != "hi!" {
102
+ t.Errorf("got %q", got)
103
+ }
104
+ })
105
+ }
@@ -0,0 +1,28 @@
1
+ package acp
2
+
3
+ // Twin helpers for a port whose original has tests. Every upstream test case gets one
4
+ // tw(...) call carrying the upstream title unchanged, or one tskip(...) with the reason it
5
+ // cannot be ported. `pigeq twins check` reads these calls and fails on a missing title, an
6
+ // unknown one (a misspelling) or one reason shared by many skips.
7
+ //
8
+ // pigeq twins list --tests <upstream>/test > port/upstream-tests.json
9
+ // pigeq twins check --ledger port/upstream-tests.json --go extensions/<name> [--files a,b]
10
+ //
11
+ // Copy this file into the port as twin_test.go and replace acp with the package name (in
12
+ // an external test package use `acp_test`). A parametrized upstream title (it contains
13
+ // `${`) is twinned once, with the template text unchanged.
14
+
15
+ import "testing"
16
+
17
+ // tw runs one exact twin: file is the upstream test file (its name without .test.mjs), title
18
+ // the upstream test title, unchanged.
19
+ func tw(t *testing.T, file, title string, fn func(t *testing.T)) {
20
+ t.Helper()
21
+ t.Run(title, fn)
22
+ }
23
+
24
+ // tskip records a named gap: the upstream case has no Go twin, with its own reason.
25
+ func tskip(t *testing.T, file, title, reason string) {
26
+ t.Helper()
27
+ t.Run(title, func(t *testing.T) { t.Skip(reason) })
28
+ }
@@ -0,0 +1,202 @@
1
+ // Package acp is the Agent Client Protocol side of pig-acp: the translation layer between
2
+ // an ACP client (an editor) and a PiG agent driven over `pig --mode rpc`. It ports the
3
+ // src/acp/* tree of pi-acp (Sergii Kozak, MIT); see the Package's CREDITS.md.
4
+ package acp
5
+
6
+ import "fmt"
7
+
8
+ // ProtocolVersion is the only ACP protocol version this adapter speaks. It is pinned to the
9
+ // schema of @agentclientprotocol/sdk 0.26.0 (PROTOCOL_VERSION = 1), the one pi-acp builds on.
10
+ const ProtocolVersion = 1
11
+
12
+ // Update is one ACP session update (`session/update` params.update). It is a JSON object,
13
+ // so an omitted field and a null field stay distinct, as in the original.
14
+ type Update = map[string]any
15
+
16
+ // Event is one record pig writes in RPC mode (an agent event or an extension UI request).
17
+ type Event = map[string]any
18
+
19
+ // ContentBlock is one block of an ACP prompt.
20
+ type ContentBlock = map[string]any
21
+
22
+ // StopReason is how a prompt turn ended. "error" is internal: ACP has no such stop reason.
23
+ type StopReason string
24
+
25
+ // Stop reasons.
26
+ const (
27
+ StopEndTurn StopReason = "end_turn"
28
+ StopCancelled StopReason = "cancelled"
29
+ StopError StopReason = "error"
30
+ )
31
+
32
+ // RequestError is a JSON-RPC error with an ACP code (the SDK's RequestError).
33
+ type RequestError struct {
34
+ Code int
35
+ Message string
36
+ Data any
37
+ }
38
+
39
+ func (e *RequestError) Error() string { return e.Message }
40
+
41
+ func withDetail(base, detail string) string {
42
+ if detail == "" {
43
+ return base
44
+ }
45
+ return fmt.Sprintf("%s: %s", base, detail)
46
+ }
47
+
48
+ // ErrParse is JSON-RPC -32700.
49
+ func ErrParse(data any, detail string) *RequestError {
50
+ return &RequestError{Code: -32700, Message: withDetail("Parse error", detail), Data: data}
51
+ }
52
+
53
+ // ErrInvalidRequest is JSON-RPC -32600.
54
+ func ErrInvalidRequest(data any, detail string) *RequestError {
55
+ return &RequestError{Code: -32600, Message: withDetail("Invalid request", detail), Data: data}
56
+ }
57
+
58
+ // ErrMethodNotFound is JSON-RPC -32601.
59
+ func ErrMethodNotFound(method string) *RequestError {
60
+ return &RequestError{Code: -32601, Message: fmt.Sprintf("%q: %s", "Method not found", method), Data: map[string]any{"method": method}}
61
+ }
62
+
63
+ // ErrInvalidParams is JSON-RPC -32602.
64
+ func ErrInvalidParams(data any, detail string) *RequestError {
65
+ return &RequestError{Code: -32602, Message: withDetail("Invalid params", detail), Data: data}
66
+ }
67
+
68
+ // ErrInternal is JSON-RPC -32603.
69
+ func ErrInternal(data any, detail string) *RequestError {
70
+ return &RequestError{Code: -32603, Message: withDetail("Internal error", detail), Data: data}
71
+ }
72
+
73
+ // ErrAuthRequired is the ACP "authentication required" error, -32000.
74
+ func ErrAuthRequired(data any, detail string) *RequestError {
75
+ return &RequestError{Code: -32000, Message: withDetail("Authentication required", detail), Data: data}
76
+ }
77
+
78
+ // PermissionOption is one choice offered in `session/request_permission`.
79
+ type PermissionOption struct {
80
+ OptionID string `json:"optionId"`
81
+ Name string `json:"name"`
82
+ Kind string `json:"kind"`
83
+ }
84
+
85
+ // PermissionRequest is `session/request_permission` params.
86
+ type PermissionRequest struct {
87
+ SessionID string `json:"sessionId"`
88
+ ToolCall map[string]any `json:"toolCall"`
89
+ Options []PermissionOption `json:"options"`
90
+ }
91
+
92
+ // PermissionOutcome is the client's answer: Outcome "selected" (with OptionID) or "cancelled".
93
+ type PermissionOutcome struct {
94
+ Outcome string `json:"outcome"`
95
+ OptionID string `json:"optionId,omitempty"`
96
+ }
97
+
98
+ // PermissionResponse is `session/request_permission` result.
99
+ type PermissionResponse struct {
100
+ Outcome PermissionOutcome `json:"outcome"`
101
+ }
102
+
103
+ // Conn is what the adapter needs from the ACP connection to the client
104
+ // (the SDK's AgentSideConnection subset pi-acp uses).
105
+ type Conn interface {
106
+ // SessionUpdate sends a `session/update` notification.
107
+ SessionUpdate(sessionID string, update Update) error
108
+ // RequestPermission sends `session/request_permission` and waits for the answer.
109
+ RequestPermission(req PermissionRequest) (PermissionResponse, error)
110
+ }
111
+
112
+ // Proc is the pi RPC child process as the session sees it (pirpc.Process implements it).
113
+ type Proc interface {
114
+ OnEvent(handler func(Event)) (unsubscribe func())
115
+ Prompt(message string, images []Image) error
116
+ Abort() error
117
+ GetState() (map[string]any, error)
118
+ GetAvailableModels() (map[string]any, error)
119
+ SetModel(provider, modelID string) error
120
+ GetAvailableThinkingLevels() ([]string, error)
121
+ SetThinkingLevel(level string) error
122
+ SetFollowUpMode(mode string) error
123
+ SetSteeringMode(mode string) error
124
+ // Compact runs manual compaction; customInstructions "" sends none.
125
+ Compact(customInstructions string) (map[string]any, error)
126
+ SetAutoCompaction(enabled bool) error
127
+ // GetSessionStats asks for get_session_stats; timeout 0 waits without a limit.
128
+ GetSessionStats(timeoutMs int) (SessionStats, error)
129
+ SetSessionName(name string) error
130
+ // ExportHTML returns the path pig wrote; outputPath "" lets pig choose.
131
+ ExportHTML(outputPath string) (string, error)
132
+ GetMessages() (map[string]any, error)
133
+ GetCommands() (map[string]any, error)
134
+ SendExtensionUIResponse(resp map[string]any) error
135
+ Dispose()
136
+ }
137
+
138
+ // Image is a pi image attachment: {type:"image", mimeType, data}.
139
+ type Image = map[string]any
140
+
141
+ // SessionStats is `get_session_stats` data, kept as a JSON object.
142
+ type SessionStats = map[string]any
143
+
144
+ // SessionStatsTimeoutMs bounds the auxiliary context-usage request.
145
+ const SessionStatsTimeoutMs = 1000
146
+
147
+ // SpawnParams are the options for starting a pi child.
148
+ type SpawnParams struct {
149
+ Cwd string
150
+ // PiCommand overrides the executable ("" = default).
151
+ PiCommand string
152
+ // SessionPath makes pi persist to this exact session file (--session).
153
+ SessionPath string
154
+ }
155
+
156
+ // SpawnFunc starts a pi RPC child. The agent uses pirpc.Spawn unless a test injects another.
157
+ type SpawnFunc func(SpawnParams) (Proc, error)
158
+
159
+ // TurnResult is the outcome of one prompt turn.
160
+ type TurnResult struct {
161
+ Reason StopReason
162
+ Err error
163
+ }
164
+
165
+ // ActiveSession is a live session as the agent uses it (Session implements it).
166
+ type ActiveSession interface {
167
+ ID() string
168
+ Cwd() string
169
+ Proc() Proc
170
+ // Prompt starts the turn now, or queues it behind the running one.
171
+ Prompt(message string, images []Image) <-chan TurnResult
172
+ Cancel() error
173
+ WasCancelRequested() bool
174
+ PublishContextUsage()
175
+ SetStartupInfo(text string)
176
+ SendStartupInfoIfPending()
177
+ }
178
+
179
+ // SessionCreateParams describe a session to create or restore.
180
+ type SessionCreateParams struct {
181
+ Cwd string
182
+ McpServers []any
183
+ Conn Conn
184
+ FileCommands []FileSlashCommand
185
+ PiCommand string
186
+ // Proc is set when restoring around an already started child.
187
+ Proc Proc
188
+ }
189
+
190
+ // SessionRegistry is the SessionManager of the original.
191
+ type SessionRegistry interface {
192
+ Create(p SessionCreateParams) (ActiveSession, error)
193
+ MaybeGet(sessionID string) ActiveSession
194
+ Get(sessionID string) (ActiveSession, error)
195
+ GetOrCreate(sessionID string, p SessionCreateParams) ActiveSession
196
+ Close(sessionID string)
197
+ CloseAllExcept(keepSessionID string)
198
+ DisposeAll()
199
+ }
200
+
201
+ // RPCError implements jsonrpc.Coded.
202
+ func (e *RequestError) RPCError() (int, string, any) { return e.Code, e.Message, e.Data }