@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,218 @@
1
+ package acp
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "time"
7
+
8
+ "github.com/MichaelKinsy/pigpen/acp/cmd/pig-acp/internal/jsonrpc"
9
+ )
10
+
11
+ // ServeOptions configure Serve.
12
+ type ServeOptions struct {
13
+ // NewAgent builds the agent for a connection; the default is NewAgent.
14
+ NewAgent func(Conn) *Agent
15
+ // DrainTimeout bounds how long Serve waits, after the client closed its output, for requests in
16
+ // flight to be answered. Zero means three seconds.
17
+ DrainTimeout time.Duration
18
+ // Done ends Serve early (SIGINT, SIGTERM); the agent is disposed as for a closed input.
19
+ Done <-chan struct{}
20
+ }
21
+
22
+ // wireConn is the agent's view of the client connection.
23
+ type wireConn struct{ c *jsonrpc.Conn }
24
+
25
+ func (w wireConn) SessionUpdate(sessionID string, update Update) error {
26
+ return w.c.Notify("session/update", map[string]any{"sessionId": sessionID, "update": update})
27
+ }
28
+
29
+ func (w wireConn) RequestPermission(req PermissionRequest) (PermissionResponse, error) {
30
+ var resp PermissionResponse
31
+ err := w.c.Call("session/request_permission", req, &resp)
32
+ return resp, err
33
+ }
34
+
35
+ // Serve speaks ACP on r and w until the client closes r or w fails. It disposes the agent
36
+ // (killing every pig child) before it returns. Nothing but protocol messages is written to w.
37
+ func Serve(r io.Reader, w io.Writer, opts ServeOptions) error {
38
+ newAgent := opts.NewAgent
39
+ if newAgent == nil {
40
+ newAgent = NewAgent
41
+ }
42
+ var agent *Agent
43
+ ready := make(chan struct{})
44
+ conn := jsonrpc.New(r, w, func(req *jsonrpc.Request) (any, error) {
45
+ <-ready
46
+ return dispatch(agent, req)
47
+ })
48
+ agent = newAgent(wireConn{conn})
49
+ close(ready)
50
+ select {
51
+ case <-conn.Done():
52
+ case <-opts.Done:
53
+ }
54
+ d := opts.DrainTimeout
55
+ if d == 0 {
56
+ d = 3 * time.Second
57
+ }
58
+ conn.Drain(d)
59
+ agent.Dispose()
60
+ return nil
61
+ }
62
+
63
+ // decode fills v from the request params; a missing or malformed body is invalid params.
64
+ func decode(req *jsonrpc.Request, v any) error {
65
+ raw := req.Params
66
+ if len(raw) == 0 {
67
+ raw = []byte("{}")
68
+ }
69
+ if err := json.Unmarshal(raw, v); err != nil {
70
+ return ErrInvalidParams(nil, err.Error())
71
+ }
72
+ return nil
73
+ }
74
+
75
+ func need(cond bool, what string) error {
76
+ if !cond {
77
+ return ErrInvalidParams(nil, what)
78
+ }
79
+ return nil
80
+ }
81
+
82
+ // dispatch routes one request. Methods the adapter does not implement, among them every method
83
+ // that would delegate to the client (fs/*, terminal/*) or that the adapter did not advertise
84
+ // (session/fork, session/resume, session/close), are Method not found.
85
+ func dispatch(base *Agent, req *jsonrpc.Request) (any, error) {
86
+ // Deferred work (available commands, usage) runs once this request's response is written.
87
+ a := base.Scoped(req.AfterResponse)
88
+ switch req.Method {
89
+ case "initialize":
90
+ var p struct {
91
+ InitializeRequest
92
+ ProtocolVersion *int `json:"protocolVersion"`
93
+ }
94
+ if err := decode(req, &p); err != nil {
95
+ return nil, err
96
+ }
97
+ if err := need(p.ProtocolVersion != nil, "protocolVersion is required"); err != nil {
98
+ return nil, err
99
+ }
100
+ p.InitializeRequest.ProtocolVersion = *p.ProtocolVersion
101
+ return a.Initialize(p.InitializeRequest)
102
+ case "authenticate":
103
+ var p AuthenticateRequest
104
+ if err := decode(req, &p); err != nil {
105
+ return nil, err
106
+ }
107
+ if err := a.Authenticate(p); err != nil {
108
+ return nil, err
109
+ }
110
+ return map[string]any{}, nil
111
+ case "session/new":
112
+ var p struct {
113
+ Cwd *string `json:"cwd"`
114
+ McpServers []any `json:"mcpServers"`
115
+ }
116
+ if err := decode(req, &p); err != nil {
117
+ return nil, err
118
+ }
119
+ if err := need(p.Cwd != nil, "cwd is required"); err != nil {
120
+ return nil, err
121
+ }
122
+ return a.NewSession(NewSessionRequest{Cwd: *p.Cwd, McpServers: orEmpty(p.McpServers)})
123
+ case "session/load":
124
+ var p struct {
125
+ SessionID *string `json:"sessionId"`
126
+ Cwd *string `json:"cwd"`
127
+ Mcp []any `json:"mcpServers"`
128
+ }
129
+ if err := decode(req, &p); err != nil {
130
+ return nil, err
131
+ }
132
+ if err := need(p.SessionID != nil && p.Cwd != nil, "sessionId and cwd are required"); err != nil {
133
+ return nil, err
134
+ }
135
+ return a.LoadSession(LoadSessionRequest{SessionID: *p.SessionID, Cwd: *p.Cwd, McpServers: orEmpty(p.Mcp)})
136
+ case "session/list":
137
+ var p ListSessionsRequest
138
+ if err := decode(req, &p); err != nil {
139
+ return nil, err
140
+ }
141
+ return a.ListSessions(p)
142
+ case "session/delete":
143
+ var p struct {
144
+ SessionID *string `json:"sessionId"`
145
+ }
146
+ if err := decode(req, &p); err != nil {
147
+ return nil, err
148
+ }
149
+ if err := need(p.SessionID != nil, "sessionId is required"); err != nil {
150
+ return nil, err
151
+ }
152
+ return a.DeleteSession(DeleteSessionRequest{SessionID: *p.SessionID})
153
+ case "session/prompt":
154
+ var p struct {
155
+ SessionID *string `json:"sessionId"`
156
+ Prompt []ContentBlock `json:"prompt"`
157
+ }
158
+ if err := decode(req, &p); err != nil {
159
+ return nil, err
160
+ }
161
+ if err := need(p.SessionID != nil && p.Prompt != nil, "sessionId and prompt are required"); err != nil {
162
+ return nil, err
163
+ }
164
+ return a.Prompt(PromptRequest{SessionID: *p.SessionID, Prompt: p.Prompt})
165
+ case "session/cancel":
166
+ var p struct {
167
+ SessionID *string `json:"sessionId"`
168
+ }
169
+ if err := decode(req, &p); err != nil {
170
+ return nil, err
171
+ }
172
+ if err := need(p.SessionID != nil, "sessionId is required"); err != nil {
173
+ return nil, err
174
+ }
175
+ if err := a.Cancel(*p.SessionID); err != nil {
176
+ return nil, err
177
+ }
178
+ return nil, nil
179
+ case "session/set_mode":
180
+ var p SetSessionModeRequest
181
+ if err := decode(req, &p); err != nil {
182
+ return nil, err
183
+ }
184
+ if err := need(p.SessionID != "", "sessionId is required"); err != nil {
185
+ return nil, err
186
+ }
187
+ return a.SetSessionMode(p)
188
+ case "session/set_config_option":
189
+ var p SetSessionConfigOptionRequest
190
+ if err := decode(req, &p); err != nil {
191
+ return nil, err
192
+ }
193
+ if err := need(p.SessionID != "" && p.ConfigID != "", "sessionId and configId are required"); err != nil {
194
+ return nil, err
195
+ }
196
+ return a.SetSessionConfigOption(p)
197
+ case "session/set_model":
198
+ var p SetSessionModelRequest
199
+ if err := decode(req, &p); err != nil {
200
+ return nil, err
201
+ }
202
+ if err := need(p.SessionID != "" && p.ModelID != "", "sessionId and modelId are required"); err != nil {
203
+ return nil, err
204
+ }
205
+ if err := a.UnstableSetSessionModel(p); err != nil {
206
+ return nil, err
207
+ }
208
+ return map[string]any{}, nil
209
+ }
210
+ return nil, ErrMethodNotFound(req.Method)
211
+ }
212
+
213
+ func orEmpty(v []any) []any {
214
+ if v == nil {
215
+ return []any{}
216
+ }
217
+ return v
218
+ }
@@ -0,0 +1,386 @@
1
+ package acp
2
+
3
+ // Protocol-level tests: a scripted ACP client speaks JSON-RPC to Serve over pipes, with the pi
4
+ // child replaced by a scripted fake. Checked against the protocol schema by
5
+ // scripts/acp-schema.test.mjs (schema 0.26.0, protocol version 1).
6
+
7
+ import (
8
+ "bufio"
9
+ "encoding/json"
10
+ "io"
11
+ "strings"
12
+ "sync"
13
+ "testing"
14
+ "time"
15
+ )
16
+
17
+ type client struct {
18
+ t *testing.T
19
+ in *io.PipeWriter
20
+ out *bufio.Scanner
21
+ mu sync.Mutex
22
+ nextI int
23
+ // inbox holds every message from the agent in arrival order.
24
+ inbox chan map[string]any
25
+ // methodsSeen records every method the agent called on the client.
26
+ methodsSeen []string
27
+ // permission answers session/request_permission.
28
+ permission func(params map[string]any) map[string]any
29
+ done chan error
30
+ }
31
+
32
+ func startServer(t *testing.T, agentSetup func(a *Agent)) *client {
33
+ t.Helper()
34
+ inR, inW := io.Pipe()
35
+ outR, outW := io.Pipe()
36
+ c := &client{t: t, in: inW, out: bufio.NewScanner(outR), inbox: make(chan map[string]any, 1024), done: make(chan error, 1)}
37
+ c.out.Buffer(make([]byte, 1<<20), 1<<26)
38
+ go func() {
39
+ c.done <- Serve(inR, outW, ServeOptions{NewAgent: func(conn Conn) *Agent {
40
+ a := NewAgent(conn)
41
+ a.store = newMemStore()
42
+ if agentSetup != nil {
43
+ agentSetup(a)
44
+ }
45
+ return a
46
+ }})
47
+ outW.Close()
48
+ }()
49
+ go func() {
50
+ for c.out.Scan() {
51
+ var m map[string]any
52
+ if err := json.Unmarshal(c.out.Bytes(), &m); err != nil {
53
+ t.Errorf("agent wrote a line that is not JSON: %q", c.out.Text())
54
+ continue
55
+ }
56
+ if method, ok := m["method"].(string); ok {
57
+ c.mu.Lock()
58
+ c.methodsSeen = append(c.methodsSeen, method)
59
+ c.mu.Unlock()
60
+ if _, isReq := m["id"]; isReq && method == "session/request_permission" {
61
+ answer := map[string]any{"outcome": map[string]any{"outcome": "cancelled"}}
62
+ if c.permission != nil {
63
+ answer = c.permission(m["params"].(map[string]any))
64
+ }
65
+ c.write(map[string]any{"jsonrpc": "2.0", "id": m["id"], "result": answer})
66
+ }
67
+ }
68
+ c.inbox <- m
69
+ }
70
+ close(c.inbox)
71
+ }()
72
+ t.Cleanup(func() { inW.Close() })
73
+ return c
74
+ }
75
+
76
+ func (c *client) write(v any) {
77
+ b, _ := json.Marshal(v)
78
+ c.mu.Lock()
79
+ defer c.mu.Unlock()
80
+ done := make(chan error, 1)
81
+ go func() { _, err := c.in.Write(append(b, '\n')); done <- err }()
82
+ select {
83
+ case err := <-done:
84
+ if err != nil {
85
+ c.t.Logf("write: %v", err)
86
+ }
87
+ case <-time.After(2 * time.Second):
88
+ c.in.CloseWithError(io.ErrClosedPipe)
89
+ c.t.Error("the server does not read its input")
90
+ }
91
+ }
92
+
93
+ func (c *client) request(method string, params any) int {
94
+ c.mu.Lock()
95
+ c.nextI++
96
+ id := c.nextI
97
+ c.mu.Unlock()
98
+ c.write(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
99
+ return id
100
+ }
101
+
102
+ func (c *client) notify(method string, params any) {
103
+ c.write(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
104
+ }
105
+
106
+ // next returns the next message from the agent.
107
+ func (c *client) next() map[string]any {
108
+ c.t.Helper()
109
+ select {
110
+ case m, ok := <-c.inbox:
111
+ if !ok {
112
+ c.t.Fatal("agent closed its output")
113
+ }
114
+ return m
115
+ case <-time.After(3 * time.Second):
116
+ c.t.Fatal("no message from the agent")
117
+ return nil
118
+ }
119
+ }
120
+
121
+ // response waits for the response to id, returning the notifications that arrived before it.
122
+ func (c *client) response(id int) (resp map[string]any, before []map[string]any) {
123
+ c.t.Helper()
124
+ for {
125
+ m := c.next()
126
+ if m["id"] == float64(id) && m["method"] == nil {
127
+ return m, before
128
+ }
129
+ before = append(before, m)
130
+ }
131
+ }
132
+
133
+ func (c *client) call(method string, params any) map[string]any {
134
+ c.t.Helper()
135
+ r, _ := c.response(c.request(method, params))
136
+ return r
137
+ }
138
+
139
+ func errCodeOf(resp map[string]any) int {
140
+ if e, ok := resp["error"].(map[string]any); ok {
141
+ return int(e["code"].(float64))
142
+ }
143
+ return 0
144
+ }
145
+
146
+ func scriptedProc(t *testing.T) *fakeProc {
147
+ p := newFakeProc()
148
+ p.getStateFn = func() (map[string]any, error) {
149
+ return map[string]any{"sessionId": "sess-1", "sessionFile": "", "thinkingLevel": "medium", "model": map[string]any{"provider": "test", "id": "model"}}, nil
150
+ }
151
+ p.getModelsFn = func() (map[string]any, error) { return models([3]string{"test", "model", "Model"}), nil }
152
+ p.getLevelsFn = func() ([]string, error) { return []string{"off", "medium"}, nil }
153
+ p.getCommandsFn = func() (map[string]any, error) {
154
+ return map[string]any{"commands": []any{map[string]any{"name": "skill:foo", "description": "Foo", "source": "skill"}}}, nil
155
+ }
156
+ return p
157
+ }
158
+
159
+ func TestServeInitialize(t *testing.T) {
160
+ t.Run("initialize advertises exactly the implemented capabilities", func(t *testing.T) {
161
+ c := startServer(t, nil)
162
+ resp := c.call("initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{}})
163
+ result, _ := resp["result"].(map[string]any)
164
+ if result["protocolVersion"] != float64(1) {
165
+ t.Fatalf("result = %v", resp)
166
+ }
167
+ jsonEqual(t, result["agentCapabilities"], map[string]any{
168
+ "loadSession": true,
169
+ "mcpCapabilities": map[string]any{"http": false, "sse": false},
170
+ "promptCapabilities": map[string]any{"image": true, "audio": false, "embeddedContext": false},
171
+ "sessionCapabilities": map[string]any{"list": map[string]any{}, "delete": map[string]any{}},
172
+ })
173
+ info, _ := result["agentInfo"].(map[string]any)
174
+ if info["name"] != "pig-acp" || info["title"] != "PiG ACP adapter" || info["version"] == "" {
175
+ t.Errorf("agentInfo = %v", info)
176
+ }
177
+ methods, _ := result["authMethods"].([]any)
178
+ if len(methods) != 1 {
179
+ t.Errorf("authMethods = %v", methods)
180
+ }
181
+ })
182
+
183
+ t.Run("initialize answers protocol version 1 to any requested version", func(t *testing.T) {
184
+ for _, requested := range []int{0, 1, 2, 99} {
185
+ c := startServer(t, nil)
186
+ resp := c.call("initialize", map[string]any{"protocolVersion": requested})
187
+ if v := resp["result"].(map[string]any)["protocolVersion"]; v != float64(1) {
188
+ t.Errorf("requested %d: answered %v", requested, v)
189
+ }
190
+ }
191
+ })
192
+
193
+ t.Run("initialize without protocolVersion is invalid params", func(t *testing.T) {
194
+ c := startServer(t, nil)
195
+ if code := errCodeOf(c.call("initialize", map[string]any{})); code != -32602 {
196
+ t.Errorf("code = %d", code)
197
+ }
198
+ })
199
+
200
+ t.Run("an unknown method is Method not found", func(t *testing.T) {
201
+ c := startServer(t, nil)
202
+ for _, m := range []string{"foo/bar", "session/fork", "session/resume", "session/close", "fs/read_text_file", "terminal/create"} {
203
+ resp := c.call(m, map[string]any{"sessionId": "s"})
204
+ if errCodeOf(resp) != -32601 {
205
+ t.Errorf("%s: response = %v", m, resp)
206
+ }
207
+ }
208
+ })
209
+
210
+ t.Run("authenticate succeeds with an empty result", func(t *testing.T) {
211
+ c := startServer(t, nil)
212
+ resp := c.call("authenticate", map[string]any{"methodId": "pi_terminal_login"})
213
+ jsonEqual(t, resp["result"], map[string]any{})
214
+ })
215
+
216
+ t.Run("session/new with a relative cwd is invalid params", func(t *testing.T) {
217
+ c := startServer(t, nil)
218
+ resp := c.call("session/new", map[string]any{"cwd": "relative/dir", "mcpServers": []any{}})
219
+ e, _ := resp["error"].(map[string]any)
220
+ if errCodeOf(resp) != -32602 || !strings.Contains(e["message"].(string), "cwd must be an absolute path") {
221
+ t.Errorf("response = %v", resp)
222
+ }
223
+ })
224
+
225
+ t.Run("session/new without cwd is invalid params", func(t *testing.T) {
226
+ c := startServer(t, nil)
227
+ if code := errCodeOf(c.call("session/new", map[string]any{"mcpServers": []any{}})); code != -32602 {
228
+ t.Errorf("code = %d", code)
229
+ }
230
+ })
231
+ }
232
+
233
+ func TestServeSessionFlow(t *testing.T) {
234
+ newFlow := func(t *testing.T, proc *fakeProc) (*client, string) {
235
+ cwd := t.TempDir()
236
+ c := startServer(t, func(a *Agent) {
237
+ a.spawn = func(SpawnParams) (Proc, error) { return proc, nil }
238
+ a.sessions = NewSessionManager(a.spawn, a.store)
239
+ })
240
+ c.call("initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{
241
+ "fs": map[string]any{"readTextFile": true, "writeTextFile": true}, "terminal": true}})
242
+ return c, cwd
243
+ }
244
+
245
+ t.Run("session/new answers first, then advertises commands, and never calls fs or terminal", func(t *testing.T) {
246
+ proc := scriptedProc(t)
247
+ c, cwd := newFlow(t, proc)
248
+ id := c.request("session/new", map[string]any{"cwd": cwd, "mcpServers": []any{}})
249
+ resp, before := c.response(id)
250
+ if len(before) != 0 {
251
+ t.Fatalf("notifications before the response: %v", before)
252
+ }
253
+ result, _ := resp["result"].(map[string]any)
254
+ if result["sessionId"] != "sess-1" {
255
+ t.Fatalf("result = %v", result)
256
+ }
257
+ if _, ok := result["configOptions"].([]any); !ok {
258
+ t.Errorf("no configOptions: %v", result)
259
+ }
260
+ // After the response: the startup info chunk and the available commands.
261
+ var kinds []string
262
+ deadline := time.After(3 * time.Second)
263
+ for len(kinds) < 2 {
264
+ select {
265
+ case m := <-c.inbox:
266
+ if m["method"] == "session/update" {
267
+ u := m["params"].(map[string]any)["update"].(map[string]any)
268
+ kinds = append(kinds, u["sessionUpdate"].(string))
269
+ if u["sessionUpdate"] == "available_commands_update" {
270
+ cmds := u["availableCommands"].([]any)
271
+ if cmds[0].(map[string]any)["name"] != "skill:foo" || len(cmds) != 9 {
272
+ t.Errorf("commands = %v", cmds)
273
+ }
274
+ }
275
+ }
276
+ case <-deadline:
277
+ t.Fatalf("saw %v", kinds)
278
+ }
279
+ }
280
+ c.mu.Lock()
281
+ defer c.mu.Unlock()
282
+ for _, m := range c.methodsSeen {
283
+ if strings.HasPrefix(m, "fs/") || strings.HasPrefix(m, "terminal/") {
284
+ t.Errorf("the adapter called %s although it advertises no delegation", m)
285
+ }
286
+ }
287
+ })
288
+
289
+ t.Run("session/prompt streams updates before the response and ends with end_turn", func(t *testing.T) {
290
+ proc := scriptedProc(t)
291
+ proc.promptHook = func() {
292
+ go func() {
293
+ proc.emit(Event{"type": "agent_start"})
294
+ proc.emit(Event{"type": "message_update", "assistantMessageEvent": map[string]any{"type": "text_delta", "delta": "Hello"}})
295
+ proc.emit(Event{"type": "message_update", "assistantMessageEvent": map[string]any{"type": "text_delta", "delta": " world"}})
296
+ proc.emit(Event{"type": "agent_end"})
297
+ proc.emit(Event{"type": "agent_settled"})
298
+ }()
299
+ }
300
+ c, cwd := newFlow(t, proc)
301
+ c.call("session/new", map[string]any{"cwd": cwd, "mcpServers": []any{}})
302
+ id := c.request("session/prompt", map[string]any{"sessionId": "sess-1", "prompt": []any{map[string]any{"type": "text", "text": "hi"}}})
303
+ resp, before := c.response(id)
304
+ if resp["result"].(map[string]any)["stopReason"] != "end_turn" {
305
+ t.Fatalf("response = %v", resp)
306
+ }
307
+ var text string
308
+ for _, m := range before {
309
+ if m["method"] != "session/update" {
310
+ continue
311
+ }
312
+ u := m["params"].(map[string]any)["update"].(map[string]any)
313
+ if u["sessionUpdate"] == "agent_message_chunk" {
314
+ if s := u["content"].(map[string]any)["text"].(string); s == "Hello" || s == " world" {
315
+ text += s
316
+ }
317
+ }
318
+ }
319
+ if text != "Hello world" {
320
+ t.Errorf("streamed %q", text)
321
+ }
322
+ if got := promptsOf(t, proc, 1); len(got) != 1 || got[0].Message != "hi" {
323
+ t.Errorf("prompts = %v", got)
324
+ }
325
+ })
326
+
327
+ t.Run("session/cancel is a notification that aborts the running turn", func(t *testing.T) {
328
+ proc := scriptedProc(t)
329
+ c, cwd := newFlow(t, proc)
330
+ c.call("session/new", map[string]any{"cwd": cwd, "mcpServers": []any{}})
331
+ id := c.request("session/prompt", map[string]any{"sessionId": "sess-1", "prompt": []any{map[string]any{"type": "text", "text": "long"}}})
332
+ eventually(t, "the prompt", func() bool { return len(proc.promptList()) == 1 })
333
+ c.notify("session/cancel", map[string]any{"sessionId": "sess-1"})
334
+ eventually(t, "the abort", func() bool { return proc.aborts() == 1 })
335
+ proc.emit(Event{"type": "agent_settled"})
336
+ resp, _ := c.response(id)
337
+ if resp["result"].(map[string]any)["stopReason"] != "cancelled" {
338
+ t.Errorf("response = %v", resp)
339
+ }
340
+ })
341
+
342
+ t.Run("an extension select becomes session/request_permission and the answer reaches pi", func(t *testing.T) {
343
+ proc := scriptedProc(t)
344
+ proc.promptHook = func() {
345
+ go func() {
346
+ proc.emit(Event{"type": "extension_ui_request", "id": "ui-9", "method": "select", "title": "Pick", "options": []any{"A", "B"}})
347
+ }()
348
+ }
349
+ c, cwd := newFlow(t, proc)
350
+ c.permission = func(params map[string]any) map[string]any {
351
+ if params["sessionId"] != "sess-1" {
352
+ t.Errorf("params = %v", params)
353
+ }
354
+ return map[string]any{"outcome": map[string]any{"outcome": "selected", "optionId": "choice-1"}}
355
+ }
356
+ c.call("session/new", map[string]any{"cwd": cwd, "mcpServers": []any{}})
357
+ id := c.request("session/prompt", map[string]any{"sessionId": "sess-1", "prompt": []any{map[string]any{"type": "text", "text": "go"}}})
358
+ eventually(t, "the UI response", func() bool { return len(proc.uiResponses()) == 1 })
359
+ jsonEqual(t, proc.uiResponses(), []any{map[string]any{"id": "ui-9", "value": "B"}})
360
+ proc.emit(Event{"type": "agent_settled"})
361
+ c.response(id)
362
+ })
363
+
364
+ t.Run("prompt for an unknown session is invalid params", func(t *testing.T) {
365
+ c, _ := newFlow(t, scriptedProc(t))
366
+ resp := c.call("session/prompt", map[string]any{"sessionId": "nope", "prompt": []any{}})
367
+ if errCodeOf(resp) != -32602 {
368
+ t.Errorf("response = %v", resp)
369
+ }
370
+ })
371
+
372
+ t.Run("closing stdin disposes every pi child and ends Serve", func(t *testing.T) {
373
+ proc := scriptedProc(t)
374
+ c, cwd := newFlow(t, proc)
375
+ c.call("session/new", map[string]any{"cwd": cwd, "mcpServers": []any{}})
376
+ c.in.Close()
377
+ select {
378
+ case <-c.done:
379
+ case <-time.After(3 * time.Second):
380
+ t.Fatal("Serve did not return after the client closed stdin")
381
+ }
382
+ if proc.disposeCount() != 1 {
383
+ t.Errorf("pi child disposed %d times", proc.disposeCount())
384
+ }
385
+ })
386
+ }