@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.
- package/CREDITS.md +23 -0
- package/LICENSE +22 -0
- package/README.md +110 -0
- package/extensions/acp/cmd/pig-acp/e2e_llm_test.go +168 -0
- package/extensions/acp/cmd/pig-acp/e2e_test.go +541 -0
- package/extensions/acp/cmd/pig-acp/go.mod +6 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent.go +784 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_builtin_test.go +536 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_commands_test.go +199 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_config_test.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_helpers_test.go +215 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_load_test.go +333 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_prompt.go +289 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/agent_session_test.go +429 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/auth.go +61 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/bench_test.go +47 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/helpers_test.go +421 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/main_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/mutation_extra_test.go +142 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/paths.go +77 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/pisessions.go +317 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/protocol.go +188 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server.go +218 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/server_test.go +386 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session.go +1068 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_diff_test.go +183 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_events_test.go +443 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_extra_test.go +547 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/session_usage_test.go +198 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/settings.go +79 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash.go +278 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/slash_test.go +128 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/startup.go +156 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store.go +118 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/store_paths_test.go +466 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate.go +352 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/translate_test.go +105 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/acp/types.go +202 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/bench_test.go +24 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc.go +302 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/jsonrpc_test.go +320 -0
- package/extensions/acp/cmd/pig-acp/internal/jsonrpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_unix.go +19 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/kill_windows.go +11 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process.go +530 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/process_test.go +355 -0
- package/extensions/acp/cmd/pig-acp/internal/pirpc/twin_test.go +28 -0
- package/extensions/acp/cmd/pig-acp/main.go +177 -0
- package/extensions/acp/cmd/pig-acp/main_test.go +213 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_unix.go +10 -0
- package/extensions/acp/cmd/pig-acp/sigpipe_windows.go +5 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/extension.go +28 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/ask/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/extension.go +37 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.mod +7 -0
- package/extensions/acp/cmd/pig-acp/testdata/gate/go.sum +2 -0
- package/extensions/acp/cmd/pig-acp/twin_test.go +28 -0
- package/extensions/acp/extension.go +63 -0
- package/extensions/acp/extension_test.go +111 -0
- package/extensions/acp/fakehost_test.go +548 -0
- package/extensions/acp/go.mod +6 -0
- package/extensions/acp/go.sum +2 -0
- package/package.json +41 -0
- package/port/PORT.md +147 -0
- package/port/interop/package-lock.json +89 -0
- package/port/interop/package.json +13 -0
- package/port/interop/run.mjs +229 -0
- package/port/mutate.mjs +110 -0
- package/port/mutation-results.txt +107 -0
- package/port/mutations.json +748 -0
- package/port/oracle/pi-acp/LICENSE +21 -0
- package/port/oracle/pi-acp/README.md +209 -0
- package/port/oracle/pi-acp/package.json +65 -0
- package/port/oracle/pi-acp/src/acp/agent.ts +1661 -0
- package/port/oracle/pi-acp/src/acp/auth-required.ts +37 -0
- package/port/oracle/pi-acp/src/acp/auth.ts +59 -0
- package/port/oracle/pi-acp/src/acp/paths.ts +15 -0
- package/port/oracle/pi-acp/src/acp/pi-commands.ts +59 -0
- package/port/oracle/pi-acp/src/acp/pi-sessions.ts +333 -0
- package/port/oracle/pi-acp/src/acp/pi-settings.ts +75 -0
- package/port/oracle/pi-acp/src/acp/session-store.ts +68 -0
- package/port/oracle/pi-acp/src/acp/session.ts +1078 -0
- package/port/oracle/pi-acp/src/acp/slash-commands.ts +197 -0
- package/port/oracle/pi-acp/src/acp/translate/bash.ts +100 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-messages.ts +17 -0
- package/port/oracle/pi-acp/src/acp/translate/pi-tools.ts +51 -0
- package/port/oracle/pi-acp/src/acp/translate/prompt.ts +71 -0
- package/port/oracle/pi-acp/src/index.ts +82 -0
- package/port/oracle/pi-acp/src/pi-rpc/command.ts +16 -0
- package/port/oracle/pi-acp/src/pi-rpc/process.ts +432 -0
- package/port/oracle/pi-acp/test/component/agent-steering-followup-modes.test.ts +143 -0
- package/port/oracle/pi-acp/test/component/session-diff.test.ts +214 -0
- package/port/oracle/pi-acp/test/component/session-events.test.ts +1102 -0
- package/port/oracle/pi-acp/test/component/session-list-and-load.test.ts +129 -0
- package/port/oracle/pi-acp/test/component/session-list-custom-session-dir.test.ts +52 -0
- package/port/oracle/pi-acp/test/component/session-list-scoped.test.ts +76 -0
- package/port/oracle/pi-acp/test/component/session-load-toolresult.test.ts +68 -0
- package/port/oracle/pi-acp/test/component/session-queue-cancel.test.ts +43 -0
- package/port/oracle/pi-acp/test/component/session-slash-commands.test.ts +37 -0
- package/port/oracle/pi-acp/test/component/session-thinking-modes.test.ts +22 -0
- package/port/oracle/pi-acp/test/component/session-title-long-session.test.ts +56 -0
- package/port/oracle/pi-acp/test/component/session-updatedAt-message-only.test.ts +57 -0
- package/port/oracle/pi-acp/test/helpers/fakes.ts +87 -0
- package/port/oracle/pi-acp/test/unit/auth-methods-terminal-auth-meta.test.ts +22 -0
- package/port/oracle/pi-acp/test/unit/builtin-commands.test.ts +60 -0
- package/port/oracle/pi-acp/test/unit/context-usage.test.ts +204 -0
- package/port/oracle/pi-acp/test/unit/merge-commands.test.ts +19 -0
- package/port/oracle/pi-acp/test/unit/model-thinking-levels.test.ts +268 -0
- package/port/oracle/pi-acp/test/unit/new-session-auth-required-when-no-models.test.ts +51 -0
- package/port/oracle/pi-acp/test/unit/new-session-pi-not-found.test.ts +26 -0
- package/port/oracle/pi-acp/test/unit/new-session-runtime-startup-errors.test.ts +148 -0
- package/port/oracle/pi-acp/test/unit/pi-command.test.ts +44 -0
- package/port/oracle/pi-acp/test/unit/pi-commands.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/pi-enable-embed-context-flag.test.ts +35 -0
- package/port/oracle/pi-acp/test/unit/pi-messages.test.ts +29 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-request-timeout.test.ts +137 -0
- package/port/oracle/pi-acp/test/unit/pi-rpc-session-path.test.ts +93 -0
- package/port/oracle/pi-acp/test/unit/pi-tools.test.ts +40 -0
- package/port/oracle/pi-acp/test/unit/prompt-to-pi-message.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/session-config-options.test.ts +217 -0
- package/port/oracle/pi-acp/test/unit/session-delete.test.ts +170 -0
- package/port/oracle/pi-acp/test/unit/session-restore.test.ts +239 -0
- package/port/oracle/pi-acp/test/unit/slash-commands.test.ts +36 -0
- package/port/oracle/pi-acp/test/unit/startup-info-env.test.ts +84 -0
- package/port/oracle/pi-acp/test/unit/startup-info-load-session.test.ts +53 -0
- package/port/oracle/pi-acp/test/unit/startup-info-project-packages.test.ts +74 -0
- package/port/oracle/pi-acp/test/unit/stdout-destroyed-does-not-crash.test.ts +32 -0
- package/port/oracle/pi-acp/test/unit/thinking-level-rpc.test.ts +43 -0
- package/port/schema/LICENSE +191 -0
- package/port/schema/schema.json +8015 -0
- package/port/upstream-tests.json +201 -0
- 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
|
+
}
|