@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,547 @@
1
+ package acp
2
+
3
+ // Layer-2 cases for session.ts branches the original's tests do not reach.
4
+
5
+ import (
6
+ "errors"
7
+ "os"
8
+ "path/filepath"
9
+ "testing"
10
+ "time"
11
+ )
12
+
13
+ func toolcall(kind string, tc map[string]any) Event {
14
+ ame := map[string]any{"type": kind}
15
+ for k, v := range tc {
16
+ ame[k] = v
17
+ }
18
+ return Event{"type": "message_update", "assistantMessageEvent": ame}
19
+ }
20
+
21
+ func TestSessionToolCalls(t *testing.T) {
22
+ t.Run("a streamed tool call starts pending and never goes back after tool_execution_start", func(t *testing.T) {
23
+ conn, proc := newFakeConn(), newFakeProc()
24
+ s := newTestSession(cwdNow(t), proc, conn)
25
+ call := map[string]any{"toolCall": map[string]any{"id": "t1", "name": "read", "arguments": map[string]any{"path": "/a"}}}
26
+ proc.emit(toolcall("toolcall_start", call))
27
+ proc.emit(Event{"type": "tool_execution_start", "toolCallId": "t1", "toolName": "read", "args": map[string]any{"path": "/a"}})
28
+ proc.emit(toolcall("toolcall_delta", call)) // late delta after execution started
29
+ settle(t, s)
30
+ var statuses []any
31
+ var kinds []string
32
+ for _, u := range conn.all() {
33
+ statuses = append(statuses, u.Update["status"])
34
+ kinds = append(kinds, str(u.Update["sessionUpdate"]))
35
+ }
36
+ jsonEqual(t, statuses, []any{"pending", "in_progress", "in_progress"})
37
+ jsonEqual(t, kinds, []any{"tool_call", "tool_call_update", "tool_call_update"})
38
+ if conn.all()[0].Update["kind"] != "read" {
39
+ t.Errorf("kind = %v", conn.all()[0].Update["kind"])
40
+ }
41
+ })
42
+
43
+ t.Run("streamed arguments come from the partial message when the event has no toolCall", func(t *testing.T) {
44
+ conn, proc := newFakeConn(), newFakeProc()
45
+ s := newTestSession(cwdNow(t), proc, conn)
46
+ proc.emit(Event{"type": "message_update", "assistantMessageEvent": map[string]any{
47
+ "type": "toolcall_delta", "contentIndex": 1,
48
+ "partial": map[string]any{"content": []any{
49
+ map[string]any{"type": "text"},
50
+ map[string]any{"type": "toolCall", "id": "t9", "name": "write", "partialArgs": `{"path":"/x"}`}}}}})
51
+ settle(t, s)
52
+ ups := conn.all()
53
+ if len(ups) != 1 || ups[0].Update["toolCallId"] != "t9" || ups[0].Update["kind"] != "edit" {
54
+ t.Fatalf("updates = %+v", ups)
55
+ }
56
+ jsonEqual(t, ups[0].Update["rawInput"], map[string]any{"path": "/x"})
57
+ })
58
+
59
+ t.Run("unparseable partial arguments are passed as {partialArgs}", func(t *testing.T) {
60
+ conn, proc := newFakeConn(), newFakeProc()
61
+ s := newTestSession(cwdNow(t), proc, conn)
62
+ proc.emit(toolcall("toolcall_delta", map[string]any{"toolCall": map[string]any{"id": "t1", "name": "bash", "partialArgs": `{"comm`}}))
63
+ settle(t, s)
64
+ ups := conn.all()
65
+ if len(ups) != 1 {
66
+ t.Fatalf("updates = %+v", ups)
67
+ }
68
+ // bash keeps the tool name as the title when there is no command yet.
69
+ if ups[0].Update["title"] != "bash" || ups[0].Update["kind"] != "execute" || ups[0].Update["status"] != "pending" {
70
+ t.Errorf("update = %v", ups[0].Update)
71
+ }
72
+ })
73
+
74
+ t.Run("a tool call without an id is ignored", func(t *testing.T) {
75
+ conn, proc := newFakeConn(), newFakeProc()
76
+ s := newTestSession(cwdNow(t), proc, conn)
77
+ proc.emit(toolcall("toolcall_start", map[string]any{"toolCall": map[string]any{"name": "read"}}))
78
+ settle(t, s)
79
+ if n := len(conn.all()); n != 0 {
80
+ t.Errorf("%d updates", n)
81
+ }
82
+ })
83
+
84
+ t.Run("a failed tool ends failed with its text and raw output", func(t *testing.T) {
85
+ conn, proc := newFakeConn(), newFakeProc()
86
+ s := newTestSession(cwdNow(t), proc, conn)
87
+ proc.emit(Event{"type": "tool_execution_start", "toolCallId": "t1", "toolName": "read", "args": map[string]any{"path": "/nope"}})
88
+ proc.emit(Event{"type": "tool_execution_end", "toolCallId": "t1", "isError": true,
89
+ "result": map[string]any{"content": []any{map[string]any{"type": "text", "text": "ENOENT"}}}})
90
+ settle(t, s)
91
+ end := conn.all()[1].Update
92
+ if end["status"] != "failed" {
93
+ t.Errorf("status = %v", end["status"])
94
+ }
95
+ jsonEqual(t, end["content"], []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": "ENOENT"}}})
96
+ if _, ok := end["rawOutput"]; !ok {
97
+ t.Error("rawOutput missing")
98
+ }
99
+ })
100
+
101
+ t.Run("a failed edit gets no diff even when the file changed", func(t *testing.T) {
102
+ dir := tmp(t)
103
+ file := filepath.Join(dir, "a.txt")
104
+ write(t, file, "before\n")
105
+ conn, proc := newFakeConn(), newFakeProc()
106
+ s := newTestSession(dir, proc, conn)
107
+ proc.emit(Event{"type": "tool_execution_start", "toolCallId": "t1", "toolName": "edit", "args": map[string]any{"path": "a.txt"}})
108
+ write(t, file, "after\n")
109
+ proc.emit(Event{"type": "tool_execution_end", "toolCallId": "t1", "isError": true, "result": map[string]any{"content": []any{map[string]any{"type": "text", "text": "failed"}}}})
110
+ settle(t, s)
111
+ end := conn.all()[1].Update
112
+ if findDiff(end) != nil || end["status"] != "failed" {
113
+ t.Errorf("end = %v", end)
114
+ }
115
+ })
116
+
117
+ t.Run("an unchanged file after edit gives text content, not a diff", func(t *testing.T) {
118
+ dir := tmp(t)
119
+ write(t, filepath.Join(dir, "a.txt"), "same\n")
120
+ conn, proc := newFakeConn(), newFakeProc()
121
+ s := newTestSession(dir, proc, conn)
122
+ proc.emit(Event{"type": "tool_execution_start", "toolCallId": "t1", "toolName": "edit", "args": map[string]any{"path": "a.txt"}})
123
+ proc.emit(okResult("no change"))
124
+ settle(t, s)
125
+ end := completedToolUpdate(conn, "t1")
126
+ if findDiff(end) != nil {
127
+ t.Error("diff for an unchanged file")
128
+ }
129
+ jsonEqual(t, end["content"], []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": "no change"}}})
130
+ })
131
+
132
+ t.Run("tool_execution_update on an edit carries no content and no raw output", func(t *testing.T) {
133
+ dir := tmp(t)
134
+ write(t, filepath.Join(dir, "a.txt"), "x\n")
135
+ conn, proc := newFakeConn(), newFakeProc()
136
+ s := newTestSession(dir, proc, conn)
137
+ proc.emit(Event{"type": "tool_execution_start", "toolCallId": "t1", "toolName": "edit", "args": map[string]any{"path": "a.txt"}})
138
+ proc.emit(Event{"type": "tool_execution_update", "toolCallId": "t1", "partialResult": map[string]any{"content": []any{map[string]any{"type": "text", "text": "partial"}}}})
139
+ settle(t, s)
140
+ u := conn.all()[1].Update
141
+ if _, ok := u["content"]; ok {
142
+ t.Errorf("content = %v", u["content"])
143
+ }
144
+ if _, ok := u["rawOutput"]; ok {
145
+ t.Errorf("rawOutput = %v", u["rawOutput"])
146
+ }
147
+ })
148
+
149
+ t.Run("non-bash tool updates carry text and raw output", func(t *testing.T) {
150
+ conn, proc := newFakeConn(), newFakeProc()
151
+ s := newTestSession(cwdNow(t), proc, conn)
152
+ proc.emit(Event{"type": "tool_execution_start", "toolCallId": "t1", "toolName": "grep", "args": map[string]any{}})
153
+ proc.emit(Event{"type": "tool_execution_update", "toolCallId": "t1", "partialResult": map[string]any{"content": []any{map[string]any{"type": "text", "text": "hit"}}}})
154
+ settle(t, s)
155
+ ups := conn.all()
156
+ if ups[0].Update["kind"] != "other" {
157
+ t.Errorf("kind = %v", ups[0].Update["kind"])
158
+ }
159
+ jsonEqual(t, ups[1].Update["content"], []any{map[string]any{"type": "content", "content": map[string]any{"type": "text", "text": "hit"}}})
160
+ if _, ok := ups[1].Update["rawOutput"]; !ok {
161
+ t.Error("rawOutput missing")
162
+ }
163
+ })
164
+
165
+ t.Run("bash output is sent as appended deltas, and a failed bash ends with its exit code", func(t *testing.T) {
166
+ conn, proc := newFakeConn(), newFakeProc()
167
+ s := newTestSession(cwdNow(t), proc, conn)
168
+ text := func(v string) map[string]any {
169
+ return map[string]any{"content": []any{map[string]any{"type": "text", "text": v}}}
170
+ }
171
+ proc.emit(Event{"type": "tool_execution_start", "toolCallId": "b1", "toolName": "bash", "args": map[string]any{"command": "make"}})
172
+ proc.emit(Event{"type": "tool_execution_update", "toolCallId": "b1", "partialResult": text("one\n")})
173
+ proc.emit(Event{"type": "tool_execution_update", "toolCallId": "b1", "partialResult": text("one\ntwo\n")})
174
+ proc.emit(Event{"type": "tool_execution_end", "toolCallId": "b1", "isError": true, "result": map[string]any{"content": []any{map[string]any{"type": "text", "text": "one\ntwo\nboom\n"}}, "details": map[string]any{"exitCode": 2}}})
175
+ settle(t, s)
176
+ var data []string
177
+ for _, u := range conn.all()[1:] {
178
+ meta, _ := u.Update["_meta"].(map[string]any)
179
+ if o, ok := meta["terminal_output"].(map[string]any); ok {
180
+ data = append(data, str(o["data"]))
181
+ }
182
+ }
183
+ jsonEqual(t, data, []any{"one\n", "two\n", "boom\n"})
184
+ last := conn.all()[len(conn.all())-1].Update
185
+ if last["status"] != "failed" {
186
+ t.Errorf("status = %v", last["status"])
187
+ }
188
+ jsonEqual(t, last["_meta"].(map[string]any)["terminal_exit"], map[string]any{"terminal_id": "b1", "exit_code": 2, "signal": nil})
189
+ })
190
+
191
+ t.Run("a bash tool call that streamed first gets a terminal only once", func(t *testing.T) {
192
+ conn, proc := newFakeConn(), newFakeProc()
193
+ s := newTestSession(cwdNow(t), proc, conn)
194
+ call := map[string]any{"toolCall": map[string]any{"id": "b1", "name": "bash", "arguments": map[string]any{"command": "ls"}}}
195
+ proc.emit(toolcall("toolcall_start", call))
196
+ proc.emit(Event{"type": "tool_execution_start", "toolCallId": "b1", "toolName": "bash", "args": map[string]any{"command": "ls"}})
197
+ settle(t, s)
198
+ ups := conn.all()
199
+ if _, ok := ups[0].Update["content"]; !ok {
200
+ t.Error("the first bash update has no terminal content")
201
+ }
202
+ if _, ok := ups[1].Update["content"]; ok {
203
+ t.Error("the second bash update repeats the terminal content")
204
+ }
205
+ if ups[0].Update["sessionUpdate"] != "tool_call" || ups[1].Update["sessionUpdate"] != "tool_call_update" {
206
+ t.Errorf("kinds = %v", conn.kinds())
207
+ }
208
+ })
209
+
210
+ t.Run("tool events without an id are ignored", func(t *testing.T) {
211
+ conn, proc := newFakeConn(), newFakeProc()
212
+ s := newTestSession(cwdNow(t), proc, conn)
213
+ proc.emit(Event{"type": "tool_execution_update", "partialResult": map[string]any{}})
214
+ proc.emit(Event{"type": "tool_execution_end", "result": map[string]any{}})
215
+ settle(t, s)
216
+ if n := len(conn.all()); n != 0 {
217
+ t.Errorf("%d updates", n)
218
+ }
219
+ })
220
+ }
221
+
222
+ func TestSessionTurns(t *testing.T) {
223
+ t.Run("a turn publishes queue depth around its start and end", func(t *testing.T) {
224
+ conn, proc := newFakeConn(), newFakeProc()
225
+ s := newTestSession(cwdNow(t), proc, conn)
226
+ p := s.Prompt("hello", nil)
227
+ settledTurn(proc)
228
+ wait(t, p)
229
+ var metas []any
230
+ for _, u := range conn.ofKind("session_info_update") {
231
+ metas = append(metas, u.Update["_meta"])
232
+ }
233
+ jsonEqual(t, metas, []any{
234
+ map[string]any{"piAcp": map[string]any{"queueDepth": 0, "running": true}},
235
+ map[string]any{"piAcp": map[string]any{"queueDepth": 0, "running": false}}})
236
+ })
237
+
238
+ t.Run("a queued prompt is announced, then started with a remaining count", func(t *testing.T) {
239
+ conn, proc := newFakeConn(), newFakeProc()
240
+ s := newTestSession(cwdNow(t), proc, conn)
241
+ first := s.Prompt("one", nil)
242
+ second := s.Prompt("two", nil)
243
+ settledTurn(proc)
244
+ wait(t, first)
245
+ eventually(t, "the second prompt", func() bool { return len(proc.promptList()) == 2 })
246
+ settledTurn(proc)
247
+ wait(t, second)
248
+ var texts []string
249
+ for _, u := range conn.ofKind("agent_message_chunk") {
250
+ texts = append(texts, str(u.Update["content"].(map[string]any)["text"]))
251
+ }
252
+ jsonEqual(t, texts, []any{"Queued message (position 1).", "Starting queued message. (0 remaining)"})
253
+ })
254
+
255
+ t.Run("cancel with queued prompts says the queue was cleared", func(t *testing.T) {
256
+ conn, proc := newFakeConn(), newFakeProc()
257
+ s := newTestSession(cwdNow(t), proc, conn)
258
+ first := s.Prompt("one", nil)
259
+ s.Prompt("two", nil)
260
+ if err := s.Cancel(); err != nil {
261
+ t.Fatal(err)
262
+ }
263
+ settledTurn(proc)
264
+ wait(t, first)
265
+ found := false
266
+ for _, u := range conn.ofKind("agent_message_chunk") {
267
+ if u.Update["content"].(map[string]any)["text"] == "Cleared queued prompts." {
268
+ found = true
269
+ }
270
+ }
271
+ if !found {
272
+ t.Error("no 'Cleared queued prompts.' notice")
273
+ }
274
+ })
275
+
276
+ t.Run("cancel with nothing running still aborts pi and is harmless", func(t *testing.T) {
277
+ conn, proc := newFakeConn(), newFakeProc()
278
+ s := newTestSession(cwdNow(t), proc, conn)
279
+ if err := s.Cancel(); err != nil {
280
+ t.Fatal(err)
281
+ }
282
+ if proc.aborts() != 1 || len(conn.all()) != 0 {
283
+ t.Errorf("aborts=%d updates=%v", proc.aborts(), conn.all())
284
+ }
285
+ })
286
+
287
+ t.Run("a prompt pi rejects ends as an error turn and clears the queue depth", func(t *testing.T) {
288
+ conn, proc := newFakeConn(), newFakeProc()
289
+ proc.promptErr = errors.New("pi prompt failed: boom")
290
+ s := newTestSession(cwdNow(t), proc, conn)
291
+ r := wait(t, s.Prompt("hi", nil))
292
+ if r.Reason != StopError || r.Err != nil {
293
+ t.Fatalf("r = %+v", r)
294
+ }
295
+ s.flushEmits()
296
+ last := conn.ofKind("session_info_update")
297
+ if len(last) == 0 {
298
+ t.Fatal("no session_info_update")
299
+ }
300
+ jsonEqual(t, last[len(last)-1].Update["_meta"], map[string]any{"piAcp": map[string]any{"queueDepth": 0, "running": false}})
301
+ })
302
+
303
+ t.Run("a prompt pi rejects for lack of credentials is AUTH_REQUIRED", func(t *testing.T) {
304
+ conn, proc := newFakeConn(), newFakeProc()
305
+ proc.promptErr = errors.New("pi prompt failed: No API key found for provider")
306
+ s := newTestSession(cwdNow(t), proc, conn)
307
+ r := wait(t, s.Prompt("hi", nil))
308
+ re, ok := r.Err.(*RequestError)
309
+ if !ok || re.Code != -32000 {
310
+ t.Fatalf("r = %+v", r)
311
+ }
312
+ })
313
+
314
+ t.Run("a cancelled prompt that pi rejects reports cancelled", func(t *testing.T) {
315
+ conn, proc := newFakeConn(), newFakeProc()
316
+ release := make(chan struct{})
317
+ proc.promptErr = errors.New("aborted")
318
+ proc.promptHook = func() { <-release }
319
+ s := newTestSession(cwdNow(t), proc, conn)
320
+ p := s.Prompt("hi", nil)
321
+ if err := s.Cancel(); err != nil {
322
+ t.Fatal(err)
323
+ }
324
+ close(release)
325
+ if r := wait(t, p); r.Reason != StopCancelled {
326
+ t.Errorf("r = %+v", r)
327
+ }
328
+ })
329
+
330
+ t.Run("the next turn starts with the cancel flag cleared", func(t *testing.T) {
331
+ conn, proc := newFakeConn(), newFakeProc()
332
+ s := newTestSession(cwdNow(t), proc, conn)
333
+ p := s.Prompt("one", nil)
334
+ s.Cancel()
335
+ settledTurn(proc)
336
+ wait(t, p)
337
+ p2 := s.Prompt("two", nil)
338
+ if s.WasCancelRequested() {
339
+ t.Error("cancel flag leaked into the next turn")
340
+ }
341
+ settledTurn(proc)
342
+ if r := wait(t, p2); r.Reason != StopEndTurn {
343
+ t.Errorf("r = %+v", r)
344
+ }
345
+ })
346
+
347
+ t.Run("startup info is sent once, on the first prompt when not sent earlier", func(t *testing.T) {
348
+ conn, proc := newFakeConn(), newFakeProc()
349
+ s := newTestSession(cwdNow(t), proc, conn)
350
+ s.SetStartupInfo("hello startup")
351
+ p := s.Prompt("go", nil)
352
+ settledTurn(proc)
353
+ wait(t, p)
354
+ n := 0
355
+ for _, u := range conn.ofKind("agent_message_chunk") {
356
+ if u.Update["content"].(map[string]any)["text"] == "hello startup" {
357
+ n++
358
+ }
359
+ }
360
+ if n != 1 {
361
+ t.Errorf("startup info sent %d times", n)
362
+ }
363
+ })
364
+ }
365
+
366
+ func TestSessionExtensionUI(t *testing.T) {
367
+ t.Run("a select without options is cancelled without asking the client", func(t *testing.T) {
368
+ conn, proc := newFakeConn(), newFakeProc()
369
+ newTestSession(cwdNow(t), proc, conn)
370
+ proc.emit(Event{"type": "extension_ui_request", "id": "u1", "method": "select", "title": "x", "options": []any{}})
371
+ eventually(t, "the response", func() bool { return len(proc.uiResponses()) == 1 })
372
+ jsonEqual(t, proc.uiResponses(), []any{map[string]any{"id": "u1", "cancelled": true}})
373
+ if n := len(conn.permissions()); n != 0 {
374
+ t.Errorf("%d permission requests", n)
375
+ }
376
+ })
377
+ t.Run("a cancelled select answers cancelled, a foreign option id too", func(t *testing.T) {
378
+ for _, outcome := range []PermissionOutcome{{Outcome: "cancelled"}, {Outcome: "selected", OptionID: "allow"}, {Outcome: "selected", OptionID: "choice-9"}, {Outcome: "selected", OptionID: "choice-01"}} {
379
+ conn, proc := newFakeConn(), newFakeProc()
380
+ conn.nextPermission = PermissionResponse{Outcome: outcome}
381
+ newTestSession(cwdNow(t), proc, conn)
382
+ proc.emit(Event{"type": "extension_ui_request", "id": "u1", "method": "select", "title": "x", "options": []any{"A"}})
383
+ eventually(t, "the response", func() bool { return len(proc.uiResponses()) == 1 })
384
+ jsonEqual(t, proc.uiResponses(), []any{map[string]any{"id": "u1", "cancelled": true}}, outcome)
385
+ }
386
+ })
387
+ t.Run("a failing permission request cancels the dialog", func(t *testing.T) {
388
+ conn, proc := newFakeConn(), newFakeProc()
389
+ failing := &failingPermissionConn{fakeConn: conn}
390
+ newTestSession(cwdNow(t), proc, failing)
391
+ proc.emit(Event{"type": "extension_ui_request", "id": "u1", "method": "confirm", "title": "x"})
392
+ eventually(t, "the response", func() bool { return len(proc.uiResponses()) == 1 })
393
+ jsonEqual(t, proc.uiResponses(), []any{map[string]any{"id": "u1", "cancelled": true}})
394
+ })
395
+ t.Run("confirm yes answers confirmed true", func(t *testing.T) {
396
+ conn, proc := newFakeConn(), newFakeProc()
397
+ conn.nextPermission = PermissionResponse{Outcome: PermissionOutcome{Outcome: "selected", OptionID: "yes"}}
398
+ newTestSession(cwdNow(t), proc, conn)
399
+ proc.emit(Event{"type": "extension_ui_request", "id": "u1", "method": "confirm", "title": "x"})
400
+ eventually(t, "the response", func() bool { return len(proc.uiResponses()) == 1 })
401
+ jsonEqual(t, proc.uiResponses(), []any{map[string]any{"id": "u1", "confirmed": true}})
402
+ })
403
+ t.Run("a fire-and-forget or unknown method is cancelled, and a request without id is dropped", func(t *testing.T) {
404
+ conn, proc := newFakeConn(), newFakeProc()
405
+ newTestSession(cwdNow(t), proc, conn)
406
+ proc.emit(Event{"type": "extension_ui_request", "id": "u1", "method": "setStatus", "statusKey": "k"})
407
+ proc.emit(Event{"type": "extension_ui_request", "method": "select", "options": []any{"A"}})
408
+ eventually(t, "the response", func() bool { return len(proc.uiResponses()) == 1 })
409
+ time.Sleep(20 * time.Millisecond)
410
+ jsonEqual(t, proc.uiResponses(), []any{map[string]any{"id": "u1", "cancelled": true}})
411
+ })
412
+ t.Run("a notify without a message says Pi notification", func(t *testing.T) {
413
+ conn, proc := newFakeConn(), newFakeProc()
414
+ s := newTestSession(cwdNow(t), proc, conn)
415
+ proc.emit(Event{"type": "extension_ui_request", "id": "n", "method": "notify"})
416
+ eventually(t, "the ack", func() bool { return len(proc.uiResponses()) == 1 })
417
+ settle(t, s)
418
+ if got := conn.all()[0].Update["content"].(map[string]any)["text"]; got != "Pi notification" {
419
+ t.Errorf("text = %v", got)
420
+ }
421
+ })
422
+ t.Run("the permission request carries method, title, message, options, placeholder and prefill as rawInput", func(t *testing.T) {
423
+ conn, proc := newFakeConn(), newFakeProc()
424
+ newTestSession(cwdNow(t), proc, conn)
425
+ proc.emit(Event{"type": "extension_ui_request", "id": "u1", "method": "confirm", "title": "T", "message": "M", "placeholder": "P", "prefill": "F", "extra": "no"})
426
+ eventually(t, "the request", func() bool { return len(conn.permissions()) == 1 })
427
+ jsonEqual(t, conn.permissions()[0].ToolCall["rawInput"], map[string]any{"method": "confirm", "title": "T", "message": "M", "placeholder": "P", "prefill": "F"})
428
+ if conn.permissions()[0].ToolCall["title"] != "T" {
429
+ t.Errorf("title = %v", conn.permissions()[0].ToolCall["title"])
430
+ }
431
+ })
432
+ t.Run("a request without a title is titled after its method", func(t *testing.T) {
433
+ conn, proc := newFakeConn(), newFakeProc()
434
+ newTestSession(cwdNow(t), proc, conn)
435
+ proc.emit(Event{"type": "extension_ui_request", "id": "u1", "method": "confirm"})
436
+ eventually(t, "the request", func() bool { return len(conn.permissions()) == 1 })
437
+ if conn.permissions()[0].ToolCall["title"] != "Pi confirm" {
438
+ t.Errorf("title = %v", conn.permissions()[0].ToolCall["title"])
439
+ }
440
+ })
441
+ }
442
+
443
+ type failingPermissionConn struct{ *fakeConn }
444
+
445
+ func (f *failingPermissionConn) RequestPermission(PermissionRequest) (PermissionResponse, error) {
446
+ return PermissionResponse{}, errors.New("client went away")
447
+ }
448
+
449
+ func TestSessionManager(t *testing.T) {
450
+ spawnBy := func(procs ...*fakeProc) (SpawnFunc, *[]SpawnParams) {
451
+ var got []SpawnParams
452
+ i := 0
453
+ return func(p SpawnParams) (Proc, error) {
454
+ got = append(got, p)
455
+ pr := procs[i]
456
+ i++
457
+ return pr, nil
458
+ }, &got
459
+ }
460
+ stateWith := func(id, file string) *fakeProc {
461
+ p := newFakeProc()
462
+ p.getStateFn = func() (map[string]any, error) { return map[string]any{"sessionId": id, "sessionFile": file}, nil }
463
+ return p
464
+ }
465
+ t.Run("Create spawns a child in the cwd, takes the id from pi, and records the file", func(t *testing.T) {
466
+ store := newMemStore()
467
+ spawn, got := spawnBy(stateWith("sid", "/f/s.jsonl"))
468
+ m := NewSessionManager(spawn, store)
469
+ s, err := m.Create(SessionCreateParams{Cwd: "/w", Conn: newFakeConn(), PiCommand: "pigx"})
470
+ if err != nil || s.ID() != "sid" {
471
+ t.Fatalf("s=%v err=%v", s, err)
472
+ }
473
+ if len(*got) != 1 || (*got)[0] != (SpawnParams{Cwd: "/w", PiCommand: "pigx"}) {
474
+ t.Errorf("spawns = %+v", *got)
475
+ }
476
+ if e := store.Get("sid"); e == nil || e.SessionFile != "/f/s.jsonl" || e.Cwd != "/w" {
477
+ t.Errorf("store = %+v", e)
478
+ }
479
+ if m.MaybeGet("sid") != s {
480
+ t.Error("not registered")
481
+ }
482
+ })
483
+ t.Run("Create falls back to a random id when pi gives none, and stores nothing without a file", func(t *testing.T) {
484
+ store := newMemStore()
485
+ p := newFakeProc()
486
+ p.getStateFn = func() (map[string]any, error) { return nil, errors.New("no state") }
487
+ spawn, _ := spawnBy(p)
488
+ s, err := NewSessionManager(spawn, store).Create(SessionCreateParams{Cwd: "/w", Conn: newFakeConn()})
489
+ if err != nil || len(s.ID()) < 16 || len(store.upserts) != 0 {
490
+ t.Fatalf("id=%q upserts=%v err=%v", s.ID(), store.upserts, err)
491
+ }
492
+ })
493
+ t.Run("a spawn error becomes an internal error with the code", func(t *testing.T) {
494
+ m := NewSessionManager(func(SpawnParams) (Proc, error) {
495
+ return nil, &spawnFailure{code: "ENOENT", msg: "Could not start pig: executable not found (command: pig)."}
496
+ }, newMemStore())
497
+ _, err := m.Create(SessionCreateParams{Cwd: "/w", Conn: newFakeConn()})
498
+ re, ok := err.(*RequestError)
499
+ if !ok || re.Code != -32603 || re.Data.(map[string]any)["code"] != "ENOENT" {
500
+ t.Errorf("err = %#v", err)
501
+ }
502
+ })
503
+ t.Run("Get of an unknown id is invalid params; Close disposes the child once", func(t *testing.T) {
504
+ p := stateWith("a", "")
505
+ spawn, _ := spawnBy(p)
506
+ m := NewSessionManager(spawn, newMemStore())
507
+ if _, err := m.Get("zz"); err == nil || err.(*RequestError).Code != -32602 {
508
+ t.Errorf("err = %v", err)
509
+ }
510
+ m.Create(SessionCreateParams{Cwd: "/w", Conn: newFakeConn()})
511
+ m.Close("a")
512
+ m.Close("a")
513
+ if p.disposeCount() != 1 || m.MaybeGet("a") != nil {
514
+ t.Errorf("disposed %d, registered %v", p.disposeCount(), m.MaybeGet("a"))
515
+ }
516
+ })
517
+ t.Run("CloseAllExcept keeps one session; DisposeAll closes the rest", func(t *testing.T) {
518
+ a, b := stateWith("a", ""), stateWith("b", "")
519
+ spawn, _ := spawnBy(a, b)
520
+ m := NewSessionManager(spawn, newMemStore())
521
+ m.Create(SessionCreateParams{Cwd: "/w", Conn: newFakeConn()})
522
+ m.Create(SessionCreateParams{Cwd: "/w", Conn: newFakeConn()})
523
+ m.CloseAllExcept("b")
524
+ if a.disposeCount() != 1 || b.disposeCount() != 0 {
525
+ t.Errorf("a=%d b=%d", a.disposeCount(), b.disposeCount())
526
+ }
527
+ m.DisposeAll()
528
+ if b.disposeCount() != 1 {
529
+ t.Errorf("b=%d", b.disposeCount())
530
+ }
531
+ })
532
+ t.Run("GetOrCreate returns the registered session for an id it already has", func(t *testing.T) {
533
+ m := NewSessionManager(nil, newMemStore())
534
+ s1 := m.GetOrCreate("x", SessionCreateParams{Cwd: "/w", Conn: newFakeConn(), Proc: newFakeProc()})
535
+ s2 := m.GetOrCreate("x", SessionCreateParams{Cwd: "/other", Conn: newFakeConn(), Proc: newFakeProc()})
536
+ if s1 != s2 || s1.Cwd() != "/w" {
537
+ t.Errorf("s1=%v s2=%v", s1, s2)
538
+ }
539
+ })
540
+ }
541
+
542
+ type spawnFailure struct{ code, msg string }
543
+
544
+ func (e *spawnFailure) Error() string { return e.msg }
545
+ func (e *spawnFailure) SpawnCode() string { return e.code }
546
+
547
+ var _ = os.Stat