@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,24 @@
1
+ package jsonrpc
2
+
3
+ import (
4
+ "io"
5
+ "testing"
6
+ )
7
+
8
+ // One session/update notification written through the connection's queue and writer goroutine.
9
+ //
10
+ // go test -run xxx -bench . -benchmem
11
+ func BenchmarkNotify(b *testing.B) {
12
+ pr, pw := io.Pipe()
13
+ defer pw.Close()
14
+ c := New(pr, io.Discard, func(*Request) (any, error) { return nil, nil })
15
+ params := map[string]any{"sessionId": "s1", "update": map[string]any{"sessionUpdate": "agent_message_chunk", "content": map[string]any{"type": "text", "text": "some streamed text from the model"}}}
16
+ b.ReportAllocs()
17
+ b.ResetTimer()
18
+ for i := 0; i < b.N; i++ {
19
+ if err := c.Notify("session/update", params); err != nil {
20
+ b.Fatal(err)
21
+ }
22
+ }
23
+ c.Drain(0)
24
+ }
@@ -0,0 +1,302 @@
1
+ // Package jsonrpc is newline-delimited JSON-RPC 2.0 over a pair of streams, the transport
2
+ // ACP uses on stdio (ndJsonStream and Connection of @agentclientprotocol/sdk).
3
+ package jsonrpc
4
+
5
+ import (
6
+ "bufio"
7
+ "bytes"
8
+ "encoding/json"
9
+ "errors"
10
+ "fmt"
11
+ "io"
12
+ "sync"
13
+ "time"
14
+ )
15
+
16
+ // Error is a JSON-RPC error object.
17
+ type Error struct {
18
+ Code int
19
+ Message string
20
+ Data any
21
+ }
22
+
23
+ func (e *Error) Error() string { return e.Message }
24
+
25
+ // Coded is implemented by errors that carry a JSON-RPC code (acp.RequestError does).
26
+ type Coded interface {
27
+ error
28
+ RPCError() (code int, message string, data any)
29
+ }
30
+
31
+ // Request is an incoming request or notification.
32
+ type Request struct {
33
+ Method string
34
+ Params json.RawMessage
35
+ // Notification is true when the message has no id.
36
+ Notification bool
37
+ after []func()
38
+ }
39
+
40
+ // AfterResponse runs fn once the response to this request has been queued for writing, so
41
+ // anything fn sends follows the response on the wire.
42
+ func (r *Request) AfterResponse(fn func()) { r.after = append(r.after, fn) }
43
+
44
+ // Handler serves one incoming message. For a notification the result is dropped.
45
+ type Handler func(req *Request) (any, error)
46
+
47
+ // maxLine bounds one message (an embedded image can be many megabytes).
48
+ const maxLine = 64 << 20
49
+
50
+ // Conn is one JSON-RPC connection. Outgoing messages go through an unbounded FIFO queue and a
51
+ // single writer goroutine, so a slow or stalled peer never blocks the agent, lines never
52
+ // interleave, and the order in which messages were queued is the order on the wire.
53
+ type Conn struct {
54
+ h Handler
55
+
56
+ mu sync.Mutex
57
+ queue [][]byte
58
+ wake chan struct{}
59
+ failed bool
60
+ writing bool
61
+ active int
62
+ nextID int64
63
+ pending map[string]chan message
64
+
65
+ done chan struct{}
66
+ doneOnce sync.Once
67
+ }
68
+
69
+ type message struct {
70
+ ID json.RawMessage `json:"id,omitempty"`
71
+ Method string `json:"method,omitempty"`
72
+ Params json.RawMessage `json:"params,omitempty"`
73
+ Result json.RawMessage `json:"result,omitempty"`
74
+ Error *wireError `json:"error,omitempty"`
75
+ }
76
+
77
+ type wireError struct {
78
+ Code int `json:"code"`
79
+ Message string `json:"message"`
80
+ Data any `json:"data,omitempty"`
81
+ }
82
+
83
+ // ErrClosed is returned when the connection can no longer carry messages.
84
+ var ErrClosed = errors.New("jsonrpc: connection closed")
85
+
86
+ // New starts reading from r and writing to w; every incoming message runs h on its own goroutine.
87
+ func New(r io.Reader, w io.Writer, h Handler) *Conn {
88
+ c := &Conn{h: h, wake: make(chan struct{}, 1), pending: map[string]chan message{}, done: make(chan struct{})}
89
+ go c.writeLoop(w)
90
+ go c.readLoop(r)
91
+ return c
92
+ }
93
+
94
+ func (c *Conn) closeDone() {
95
+ c.doneOnce.Do(func() {
96
+ close(c.done)
97
+ c.mu.Lock()
98
+ for id, ch := range c.pending {
99
+ close(ch)
100
+ delete(c.pending, id)
101
+ }
102
+ c.mu.Unlock()
103
+ })
104
+ }
105
+
106
+ func (c *Conn) writeLoop(w io.Writer) {
107
+ for {
108
+ c.mu.Lock()
109
+ if len(c.queue) == 0 {
110
+ c.mu.Unlock()
111
+ <-c.wake
112
+ continue
113
+ }
114
+ line := c.queue[0]
115
+ c.queue = c.queue[1:]
116
+ c.writing = true
117
+ c.mu.Unlock()
118
+ _, err := w.Write(line)
119
+ c.mu.Lock()
120
+ c.writing = false
121
+ c.mu.Unlock()
122
+ if err != nil {
123
+ c.mu.Lock()
124
+ c.failed = true
125
+ c.queue = nil
126
+ c.mu.Unlock()
127
+ c.closeDone()
128
+ return
129
+ }
130
+ }
131
+ }
132
+
133
+ func (c *Conn) enqueue(v any) error {
134
+ b, err := json.Marshal(v)
135
+ if err != nil {
136
+ return err
137
+ }
138
+ c.mu.Lock()
139
+ if c.failed {
140
+ c.mu.Unlock()
141
+ return ErrClosed
142
+ }
143
+ c.queue = append(c.queue, append(b, '\n'))
144
+ c.mu.Unlock()
145
+ select {
146
+ case c.wake <- struct{}{}:
147
+ default:
148
+ }
149
+ return nil
150
+ }
151
+
152
+ func (c *Conn) readLoop(r io.Reader) {
153
+ br := bufio.NewReaderSize(r, 1<<20)
154
+ for {
155
+ line, err := readLine(br)
156
+ if len(bytes.TrimSpace(line)) > 0 {
157
+ var m message
158
+ if json.Unmarshal(line, &m) == nil {
159
+ c.dispatch(m)
160
+ }
161
+ }
162
+ if err != nil {
163
+ c.closeDone()
164
+ return
165
+ }
166
+ }
167
+ }
168
+
169
+ func readLine(br *bufio.Reader) ([]byte, error) {
170
+ var out []byte
171
+ for {
172
+ part, err := br.ReadSlice('\n')
173
+ out = append(out, part...)
174
+ if len(out) > maxLine {
175
+ return nil, io.ErrShortBuffer
176
+ }
177
+ if err == bufio.ErrBufferFull {
178
+ continue
179
+ }
180
+ return out, err
181
+ }
182
+ }
183
+
184
+ func (c *Conn) dispatch(m message) {
185
+ if m.Method == "" {
186
+ if len(m.ID) == 0 {
187
+ return
188
+ }
189
+ c.mu.Lock()
190
+ ch, ok := c.pending[string(m.ID)]
191
+ delete(c.pending, string(m.ID))
192
+ c.mu.Unlock()
193
+ if ok {
194
+ ch <- m
195
+ }
196
+ return
197
+ }
198
+ notification := len(m.ID) == 0
199
+ c.mu.Lock()
200
+ c.active++
201
+ c.mu.Unlock()
202
+ go c.serve(m, notification)
203
+ }
204
+
205
+ func (c *Conn) serve(m message, notification bool) {
206
+ defer func() {
207
+ c.mu.Lock()
208
+ c.active--
209
+ c.mu.Unlock()
210
+ }()
211
+ req := &Request{Method: m.Method, Params: m.Params, Notification: notification}
212
+ res, err := c.call(req)
213
+ if notification {
214
+ return
215
+ }
216
+ resp := map[string]any{"jsonrpc": "2.0", "id": m.ID}
217
+ if err != nil {
218
+ resp["error"] = toWireError(err)
219
+ } else {
220
+ resp["result"] = res
221
+ }
222
+ _ = c.enqueue(resp)
223
+ for _, fn := range req.after {
224
+ fn()
225
+ }
226
+ }
227
+
228
+ func (c *Conn) call(req *Request) (res any, err error) {
229
+ defer func() {
230
+ if p := recover(); p != nil {
231
+ res, err = nil, fmt.Errorf("panic: %v", p)
232
+ }
233
+ }()
234
+ return c.h(req)
235
+ }
236
+
237
+ func toWireError(err error) *wireError {
238
+ var coded Coded
239
+ if errors.As(err, &coded) {
240
+ code, msg, data := coded.RPCError()
241
+ return &wireError{Code: code, Message: msg, Data: data}
242
+ }
243
+ return &wireError{Code: -32603, Message: "Internal error", Data: map[string]any{"details": err.Error()}}
244
+ }
245
+
246
+ // Call sends a request and decodes its result into result (may be nil).
247
+ func (c *Conn) Call(method string, params, result any) error {
248
+ c.mu.Lock()
249
+ c.nextID++
250
+ id := c.nextID
251
+ key := fmt.Sprint(id)
252
+ ch := make(chan message, 1)
253
+ select {
254
+ case <-c.done:
255
+ c.mu.Unlock()
256
+ return ErrClosed
257
+ default:
258
+ }
259
+ c.pending[key] = ch
260
+ c.mu.Unlock()
261
+ if err := c.enqueue(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}); err != nil {
262
+ c.mu.Lock()
263
+ delete(c.pending, key)
264
+ c.mu.Unlock()
265
+ return err
266
+ }
267
+ m, ok := <-ch
268
+ if !ok {
269
+ return ErrClosed
270
+ }
271
+ if m.Error != nil {
272
+ return &Error{Code: m.Error.Code, Message: m.Error.Message, Data: m.Error.Data}
273
+ }
274
+ if result != nil && len(m.Result) > 0 {
275
+ return json.Unmarshal(m.Result, result)
276
+ }
277
+ return nil
278
+ }
279
+
280
+ // Notify sends a notification.
281
+ func (c *Conn) Notify(method string, params any) error {
282
+ return c.enqueue(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
283
+ }
284
+
285
+ // Done is closed when the input ends or a write fails for good.
286
+ func (c *Conn) Done() <-chan struct{} { return c.done }
287
+
288
+ // Drain waits until every request being served has been answered and every queued message has been
289
+ // written, or until timeout. It lets a client that closes its output right after sending a request
290
+ // (a one-shot script) still receive the answer.
291
+ func (c *Conn) Drain(timeout time.Duration) {
292
+ deadline := time.Now().Add(timeout)
293
+ for time.Now().Before(deadline) {
294
+ c.mu.Lock()
295
+ idle := c.active == 0 && len(c.queue) == 0 && !c.writing || c.failed
296
+ c.mu.Unlock()
297
+ if idle {
298
+ return
299
+ }
300
+ time.Sleep(5 * time.Millisecond)
301
+ }
302
+ }
@@ -0,0 +1,320 @@
1
+ package jsonrpc
2
+
3
+ import (
4
+ "bufio"
5
+ "encoding/json"
6
+ "errors"
7
+ "fmt"
8
+ "io"
9
+ "strings"
10
+ "sync"
11
+ "testing"
12
+ "time"
13
+ )
14
+
15
+ // peer is the client end of a Conn under test.
16
+ type peer struct {
17
+ toConn *io.PipeWriter
18
+ fromConn *bufio.Scanner
19
+ conn *Conn
20
+ }
21
+
22
+ func newPeer(t *testing.T, h Handler) *peer {
23
+ t.Helper()
24
+ inR, inW := io.Pipe()
25
+ outR, outW := io.Pipe()
26
+ p := &peer{toConn: inW, fromConn: bufio.NewScanner(outR)}
27
+ p.fromConn.Buffer(make([]byte, 1<<20), 1<<26)
28
+ p.conn = New(inR, outW, h)
29
+ t.Cleanup(func() { inW.Close(); outR.Close() })
30
+ return p
31
+ }
32
+
33
+ func (p *peer) send(t *testing.T, v any) {
34
+ t.Helper()
35
+ var line string
36
+ if s, ok := v.(string); ok {
37
+ line = s
38
+ } else {
39
+ b, _ := json.Marshal(v)
40
+ line = string(b)
41
+ }
42
+ done := make(chan error, 1)
43
+ go func() { _, err := p.toConn.Write([]byte(line + "\n")); done <- err }()
44
+ select {
45
+ case err := <-done:
46
+ if err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ case <-time.After(3 * time.Second):
50
+ p.toConn.CloseWithError(io.ErrClosedPipe)
51
+ t.Fatal("the connection does not read its input")
52
+ }
53
+ }
54
+
55
+ func (p *peer) recv(t *testing.T) map[string]any {
56
+ t.Helper()
57
+ done := make(chan map[string]any, 1)
58
+ go func() {
59
+ if !p.fromConn.Scan() {
60
+ done <- nil
61
+ return
62
+ }
63
+ var m map[string]any
64
+ if err := json.Unmarshal(p.fromConn.Bytes(), &m); err != nil {
65
+ t.Errorf("output %q is not JSON: %v", p.fromConn.Text(), err)
66
+ }
67
+ done <- m
68
+ }()
69
+ select {
70
+ case m := <-done:
71
+ return m
72
+ case <-time.After(5 * time.Second):
73
+ t.Fatal("no message from the connection")
74
+ return nil
75
+ }
76
+ }
77
+
78
+ type coded struct {
79
+ code int
80
+ msg string
81
+ data any
82
+ }
83
+
84
+ func (c coded) Error() string { return c.msg }
85
+ func (c coded) RPCError() (int, string, any) { return c.code, c.msg, c.data }
86
+
87
+ func TestConn(t *testing.T) {
88
+ t.Run("answers a request with its result and the same id", func(t *testing.T) {
89
+ p := newPeer(t, func(r *Request) (any, error) { return map[string]any{"echo": r.Method}, nil })
90
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": 7, "method": "ping", "params": map[string]any{}})
91
+ m := p.recv(t)
92
+ if m["jsonrpc"] != "2.0" || m["id"] != float64(7) {
93
+ t.Fatalf("response = %v", m)
94
+ }
95
+ if r, _ := m["result"].(map[string]any); r["echo"] != "ping" {
96
+ t.Errorf("result = %v", m["result"])
97
+ }
98
+ })
99
+
100
+ t.Run("keeps string ids and answers a null result as null", func(t *testing.T) {
101
+ p := newPeer(t, func(*Request) (any, error) { return nil, nil })
102
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": "abc", "method": "m"})
103
+ m := p.recv(t)
104
+ if m["id"] != "abc" {
105
+ t.Fatalf("response = %v", m)
106
+ }
107
+ if v, ok := m["result"]; !ok || v != nil {
108
+ t.Errorf("result = %v (present %v), want null", v, ok)
109
+ }
110
+ })
111
+
112
+ t.Run("maps a coded error to code, message and data", func(t *testing.T) {
113
+ p := newPeer(t, func(*Request) (any, error) { return nil, coded{-32602, "Invalid params: x", map[string]any{"k": 1}} })
114
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": 1, "method": "m"})
115
+ e, _ := p.recv(t)["error"].(map[string]any)
116
+ if e["code"] != float64(-32602) || e["message"] != "Invalid params: x" {
117
+ t.Fatalf("error = %v", e)
118
+ }
119
+ if d, _ := e["data"].(map[string]any); d["k"] != float64(1) {
120
+ t.Errorf("data = %v", e["data"])
121
+ }
122
+ })
123
+
124
+ t.Run("maps any other error to Internal error with the details", func(t *testing.T) {
125
+ p := newPeer(t, func(*Request) (any, error) { return nil, errors.New("boom") })
126
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": 1, "method": "m"})
127
+ e, _ := p.recv(t)["error"].(map[string]any)
128
+ if e["code"] != float64(-32603) || e["message"] != "Internal error" {
129
+ t.Fatalf("error = %v", e)
130
+ }
131
+ if d, _ := e["data"].(map[string]any); d["details"] != "boom" {
132
+ t.Errorf("data = %v", e["data"])
133
+ }
134
+ })
135
+
136
+ t.Run("skips a line that is not JSON and keeps serving", func(t *testing.T) {
137
+ p := newPeer(t, func(*Request) (any, error) { return "ok", nil })
138
+ p.send(t, "this is not json")
139
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": 2, "method": "m"})
140
+ if m := p.recv(t); m["id"] != float64(2) || m["result"] != "ok" {
141
+ t.Fatalf("response = %v", m)
142
+ }
143
+ })
144
+
145
+ t.Run("does not answer a notification", func(t *testing.T) {
146
+ got := make(chan string, 1)
147
+ p := newPeer(t, func(r *Request) (any, error) {
148
+ if r.Method == "session/cancel" {
149
+ if !r.Notification {
150
+ t.Error("Notification flag not set")
151
+ }
152
+ got <- r.Method
153
+ }
154
+ return "ignored", nil
155
+ })
156
+ p.send(t, map[string]any{"jsonrpc": "2.0", "method": "session/cancel", "params": map[string]any{"sessionId": "s"}})
157
+ if m := <-got; m != "session/cancel" {
158
+ t.Fatalf("method = %q", m)
159
+ }
160
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": 1, "method": "after"})
161
+ if m := p.recv(t); m["id"] != float64(1) {
162
+ t.Fatalf("the first output was %v, not the answer to the request", m)
163
+ }
164
+ })
165
+
166
+ t.Run("a slow request does not block a later one", func(t *testing.T) {
167
+ release := make(chan struct{})
168
+ p := newPeer(t, func(r *Request) (any, error) {
169
+ if r.Method == "slow" {
170
+ <-release
171
+ }
172
+ return r.Method, nil
173
+ })
174
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": 1, "method": "slow"})
175
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": 2, "method": "fast"})
176
+ if m := p.recv(t); m["id"] != float64(2) {
177
+ t.Fatalf("first response = %v", m)
178
+ }
179
+ close(release)
180
+ if m := p.recv(t); m["id"] != float64(1) {
181
+ t.Fatalf("second response = %v", m)
182
+ }
183
+ })
184
+
185
+ t.Run("delivers the request params untouched", func(t *testing.T) {
186
+ got := make(chan string, 1)
187
+ p := newPeer(t, func(r *Request) (any, error) { got <- string(r.Params); return nil, nil })
188
+ p.send(t, `{"jsonrpc":"2.0","id":1,"method":"m","params":{"a":[1,2,{"b":null}]}}`)
189
+ if s := <-got; s != `{"a":[1,2,{"b":null}]}` {
190
+ t.Errorf("params = %s", s)
191
+ }
192
+ })
193
+
194
+ t.Run("correlates an outgoing request with its response", func(t *testing.T) {
195
+ p := newPeer(t, func(*Request) (any, error) { return nil, nil })
196
+ type res struct {
197
+ Outcome string `json:"outcome"`
198
+ }
199
+ done := make(chan error, 1)
200
+ var out res
201
+ go func() { done <- p.conn.Call("session/request_permission", map[string]any{"sessionId": "s"}, &out) }()
202
+ req := p.recv(t)
203
+ if req["method"] != "session/request_permission" || req["jsonrpc"] != "2.0" {
204
+ t.Fatalf("request = %v", req)
205
+ }
206
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": req["id"], "result": map[string]any{"outcome": "selected"}})
207
+ if err := <-done; err != nil || out.Outcome != "selected" {
208
+ t.Fatalf("err=%v out=%+v", err, out)
209
+ }
210
+ })
211
+
212
+ t.Run("an error response to an outgoing request is returned as *Error", func(t *testing.T) {
213
+ p := newPeer(t, func(*Request) (any, error) { return nil, nil })
214
+ done := make(chan error, 1)
215
+ go func() { done <- p.conn.Call("x", nil, nil) }()
216
+ req := p.recv(t)
217
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": req["id"], "error": map[string]any{"code": -32601, "message": "nope"}})
218
+ var e *Error
219
+ if err := <-done; !errors.As(err, &e) || e.Code != -32601 || e.Message != "nope" {
220
+ t.Fatalf("err = %v", err)
221
+ }
222
+ })
223
+
224
+ t.Run("closing the input fails pending requests and closes Done", func(t *testing.T) {
225
+ p := newPeer(t, func(*Request) (any, error) { return nil, nil })
226
+ done := make(chan error, 1)
227
+ go func() { done <- p.conn.Call("x", nil, nil) }()
228
+ p.recv(t)
229
+ p.toConn.Close()
230
+ select {
231
+ case err := <-done:
232
+ if err == nil {
233
+ t.Error("pending request succeeded")
234
+ }
235
+ case <-time.After(5 * time.Second):
236
+ t.Fatal("pending request never failed")
237
+ }
238
+ select {
239
+ case <-p.conn.Done():
240
+ case <-time.After(5 * time.Second):
241
+ t.Fatal("Done never closed")
242
+ }
243
+ })
244
+
245
+ t.Run("serializes concurrent writes into whole lines", func(t *testing.T) {
246
+ p := newPeer(t, func(*Request) (any, error) { return nil, nil })
247
+ var wg sync.WaitGroup
248
+ for i := 0; i < 200; i++ {
249
+ wg.Add(1)
250
+ go func(i int) {
251
+ defer wg.Done()
252
+ _ = p.conn.Notify("session/update", map[string]any{"sessionId": "s", "n": i, "pad": strings.Repeat("x", 4096)})
253
+ }(i)
254
+ }
255
+ seen := map[float64]bool{}
256
+ for i := 0; i < 200; i++ {
257
+ m := p.recv(t)
258
+ seen[m["params"].(map[string]any)["n"].(float64)] = true
259
+ }
260
+ wg.Wait()
261
+ if len(seen) != 200 {
262
+ t.Errorf("saw %d distinct notifications", len(seen))
263
+ }
264
+ })
265
+
266
+ t.Run("accepts a very large message", func(t *testing.T) {
267
+ got := make(chan int, 1)
268
+ p := newPeer(t, func(r *Request) (any, error) { got <- len(r.Params); return nil, nil })
269
+ big := strings.Repeat("y", 8<<20)
270
+ p.send(t, fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"m","params":{"blob":%q}}`, big))
271
+ if n := <-got; n < 8<<20 {
272
+ t.Errorf("params length %d", n)
273
+ }
274
+ })
275
+
276
+ t.Run("runs AfterResponse hooks after the response was written", func(t *testing.T) {
277
+ order := make(chan string, 2)
278
+ p := newPeer(t, func(r *Request) (any, error) {
279
+ r.AfterResponse(func() { order <- "hook" })
280
+ return "done", nil
281
+ })
282
+ p.send(t, map[string]any{"jsonrpc": "2.0", "id": 1, "method": "m"})
283
+ select {
284
+ case <-order:
285
+ // The hook may only run once the response is on the wire; reading it now proves it was written.
286
+ case <-time.After(5 * time.Second):
287
+ t.Fatal("hook never ran")
288
+ }
289
+ if m := p.recv(t); m["result"] != "done" {
290
+ t.Fatalf("response = %v", m)
291
+ }
292
+ })
293
+ }
294
+
295
+ // failingWriter is a stdout that was destroyed (test/unit/stdout-destroyed-does-not-crash.test.ts).
296
+ type failingWriter struct{}
297
+
298
+ func (failingWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
299
+
300
+ func TestDestroyedOutput(t *testing.T) {
301
+ tw(t, "unit/stdout-destroyed-does-not-crash", "stdout writer: resolves even if stdout is destroyed", func(t *testing.T) {
302
+ in, _ := io.Pipe()
303
+ c := New(in, failingWriter{}, func(*Request) (any, error) { return nil, nil })
304
+ done := make(chan struct{})
305
+ go func() {
306
+ _ = c.Notify("session/update", map[string]any{"sessionId": "s"})
307
+ close(done)
308
+ }()
309
+ select {
310
+ case <-done:
311
+ case <-time.After(5 * time.Second):
312
+ t.Fatal("Notify blocked on a destroyed output")
313
+ }
314
+ select {
315
+ case <-c.Done():
316
+ case <-time.After(5 * time.Second):
317
+ t.Fatal("a failed write did not end the connection")
318
+ }
319
+ })
320
+ }
@@ -0,0 +1,28 @@
1
+ package jsonrpc
2
+
3
+ // Twin helpers for a port whose original has tests. Every upstream test case gets one
4
+ // tw(...) call carrying the upstream title unchanged, or one tskip(...) with the reason it
5
+ // cannot be ported. `pigeq twins check` reads these calls and fails on a missing title, an
6
+ // unknown one (a misspelling) or one reason shared by many skips.
7
+ //
8
+ // pigeq twins list --tests <upstream>/test > port/upstream-tests.json
9
+ // pigeq twins check --ledger port/upstream-tests.json --go extensions/<name> [--files a,b]
10
+ //
11
+ // Copy this file into the port as twin_test.go and replace jsonrpc with the package name (in
12
+ // an external test package use `jsonrpc_test`). A parametrized upstream title (it contains
13
+ // `${`) is twinned once, with the template text unchanged.
14
+
15
+ import "testing"
16
+
17
+ // tw runs one exact twin: file is the upstream test file (its name without .test.mjs), title
18
+ // the upstream test title, unchanged.
19
+ func tw(t *testing.T, file, title string, fn func(t *testing.T)) {
20
+ t.Helper()
21
+ t.Run(title, fn)
22
+ }
23
+
24
+ // tskip records a named gap: the upstream case has no Go twin, with its own reason.
25
+ func tskip(t *testing.T, file, title, reason string) {
26
+ t.Helper()
27
+ t.Run(title, func(t *testing.T) { t.Skip(reason) })
28
+ }
@@ -0,0 +1,19 @@
1
+ //go:build !windows
2
+
3
+ package pirpc
4
+
5
+ import (
6
+ "os/exec"
7
+ "syscall"
8
+ "time"
9
+ )
10
+
11
+ // terminate asks the child to stop (SIGTERM, like the original) and kills it if it lingers.
12
+ func terminate(cmd *exec.Cmd) {
13
+ if cmd.Process == nil {
14
+ return
15
+ }
16
+ _ = cmd.Process.Signal(syscall.SIGTERM)
17
+ proc := cmd.Process
18
+ time.AfterFunc(5*time.Second, func() { _ = proc.Kill() })
19
+ }
@@ -0,0 +1,11 @@
1
+ //go:build windows
2
+
3
+ package pirpc
4
+
5
+ import "os/exec"
6
+
7
+ func terminate(cmd *exec.Cmd) {
8
+ if cmd.Process != nil {
9
+ _ = cmd.Process.Kill()
10
+ }
11
+ }