Adds GET /api/workspaces/:id/acp/stream. Client opens a WebSocket,
sends {"type":"prompt","content":"..."}, and receives a stream of
{"type":"chunk","messageId","text"} events followed by exactly
one {"type":"complete","stopReason"} or {"type":"error","error"}.
Closing the WS early triggers session/cancel.
- internal/acp/messages.go: StreamEvent wire shape.
- internal/acp/client.go:
- streamChs []chan StreamEvent set; AddStream / RemoveStream.
- sendStream non-blocking fanout.
- Client.Stream(ctx, content, out) registers out, sends prompt,
emits complete/error after the prompt response, unregisters.
- handleNotification fans chunk events to all stream consumers.
- notifyWG ensures chunk ordering vs the terminal event.
- internal/acp/service.go: Service.Stream(workspaceID, content, out)
mirrors Prompt (per-workspace lock, 5-min timeout, EnsureReady).
- internal/service/acp_service.go: thin AcpService.Stream wrapper
that maps acp.StreamEvent -> model.AcpStreamEvent.
- internal/model/acp.go: AcpStreamRequest, AcpStreamEvent DTOs.
- internal/api/acp_handler.go: stream WS handler (upgrade, read
prompt, run Stream in a goroutine, write events, ping/pong, Cancel
on client close).
- internal/api/router.go: register the new route.
- internal/acp/transport.go: dispatch notifications synchronously
(vs. goroutine per notification) so chunks preserve order before
the session/prompt response.
- internal/acp/client_test.go: TestClientStreamEmitsChunkAndComplete
with a fake transport that drives a known sequence.
- internal/api/acp_handler_test.go: TestAcpStreamHandlerRoutes
smoke test using a fake opencode acp script.
Existing POST /api/workspaces/:id/acp/prompt is unchanged.
E2E: prompt 'say hi in exactly 3 words' -> 3 chunk events
('Hi',' there','!') + 1 complete {stopReason: 'end_turn'}.
Conversation: 019f3680-200f-79b0-860b-43302e60d0ea
199 lines
4.8 KiB
Go
199 lines
4.8 KiB
Go
package acp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"codespace/internal/process"
|
|
)
|
|
|
|
// transport reads NDJSON frames from a process subscription and writes NDJSON
|
|
// frames to the process stdin.
|
|
type transport struct {
|
|
stdin io.WriteCloser
|
|
sub process.Subscription
|
|
frames chan *jsonRPCMessage
|
|
pending map[int]chan *jsonRPCMessage
|
|
pendingMu sync.Mutex
|
|
nextID atomic.Int64
|
|
onRequest func(method string, params json.RawMessage, id int)
|
|
onNotification func(method string, params json.RawMessage)
|
|
onClose func()
|
|
lg *slog.Logger
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
// newTransport creates a transport bound to the given stdin writer and output
|
|
// subscription. Call start to begin the read/dispatch goroutines.
|
|
func newTransport(stdin io.WriteCloser, sub process.Subscription, lg *slog.Logger) *transport {
|
|
if lg == nil {
|
|
lg = slog.Default()
|
|
}
|
|
return &transport{
|
|
stdin: stdin,
|
|
sub: sub,
|
|
frames: make(chan *jsonRPCMessage, 64),
|
|
pending: make(map[int]chan *jsonRPCMessage),
|
|
lg: lg,
|
|
}
|
|
}
|
|
|
|
// start launches the read and dispatch loops.
|
|
func (t *transport) start() {
|
|
go t.readLoop()
|
|
go t.dispatchLoop()
|
|
}
|
|
|
|
// readLoop consumes raw bytes from the subscription, splits on newlines, and
|
|
// feeds parsed JSON-RPC frames to the frames channel.
|
|
func (t *transport) readLoop() {
|
|
defer close(t.frames)
|
|
buf := bytes.NewBuffer(make([]byte, 0, 4096))
|
|
for chunk := range t.sub.Output() {
|
|
buf.Write(chunk)
|
|
for {
|
|
data := buf.Bytes()
|
|
idx := bytes.IndexByte(data, '\n')
|
|
if idx < 0 {
|
|
break
|
|
}
|
|
line := make([]byte, idx)
|
|
copy(line, data[:idx])
|
|
buf.Next(idx + 1)
|
|
msg := &jsonRPCMessage{}
|
|
if err := json.Unmarshal(line, msg); err != nil {
|
|
t.lg.Error("acp invalid ndjson line", "error", err)
|
|
continue
|
|
}
|
|
t.frames <- msg
|
|
}
|
|
}
|
|
}
|
|
|
|
// dispatchLoop routes frames to pending request channels, notification
|
|
// handlers, or incoming-request handlers.
|
|
func (t *transport) dispatchLoop() {
|
|
defer func() {
|
|
if t.onClose != nil {
|
|
t.onClose()
|
|
}
|
|
}()
|
|
for msg := range t.frames {
|
|
switch {
|
|
case msg.ID != nil && msg.Method != "":
|
|
// Incoming request from the agent.
|
|
if t.onRequest != nil {
|
|
id := *msg.ID
|
|
go t.onRequest(msg.Method, msg.Params, id)
|
|
}
|
|
case msg.ID == nil && msg.Method != "":
|
|
// Notification from the agent.
|
|
if t.onNotification != nil {
|
|
t.onNotification(msg.Method, msg.Params)
|
|
}
|
|
case msg.ID != nil && (msg.Result != nil || msg.Error != nil):
|
|
// Response to a client-initiated request.
|
|
id := *msg.ID
|
|
t.pendingMu.Lock()
|
|
ch, ok := t.pending[id]
|
|
delete(t.pending, id)
|
|
t.pendingMu.Unlock()
|
|
if ok {
|
|
select {
|
|
case ch <- msg:
|
|
default:
|
|
}
|
|
} else {
|
|
t.lg.Debug("acp unsolicited response", "id", id)
|
|
}
|
|
default:
|
|
t.lg.Debug("acp unhandled frame", "method", msg.Method, "id", msg.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// call sends a JSON-RPC request and waits for its response.
|
|
func (t *transport) call(ctx context.Context, method string, params, result any) error {
|
|
id := int(t.nextID.Add(1))
|
|
rawParams, err := json.Marshal(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg := &jsonRPCMessage{JSONRPC: "2.0", ID: &id, Method: method, Params: rawParams}
|
|
ch := make(chan *jsonRPCMessage, 1)
|
|
t.pendingMu.Lock()
|
|
t.pending[id] = ch
|
|
t.pendingMu.Unlock()
|
|
defer func() {
|
|
t.pendingMu.Lock()
|
|
delete(t.pending, id)
|
|
t.pendingMu.Unlock()
|
|
}()
|
|
if err := t.write(msg); err != nil {
|
|
return err
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case resp := <-ch:
|
|
if resp.Error != nil {
|
|
return errors.New(resp.Error.Message)
|
|
}
|
|
if result != nil && resp.Result != nil {
|
|
return json.Unmarshal(resp.Result, result)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// sendNotification writes a JSON-RPC notification (no id).
|
|
func (t *transport) sendNotification(method string, params any) error {
|
|
rawParams, err := json.Marshal(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg := &jsonRPCMessage{JSONRPC: "2.0", Method: method, Params: rawParams}
|
|
return t.write(msg)
|
|
}
|
|
|
|
// sendResponse writes a JSON-RPC response for an agent-initiated request.
|
|
func (t *transport) sendResponse(id int, result any, rpcErr *rpcError) error {
|
|
msg := &jsonRPCMessage{JSONRPC: "2.0", ID: &id}
|
|
if rpcErr != nil {
|
|
msg.Error = rpcErr
|
|
} else {
|
|
raw, err := json.Marshal(result)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg.Result = raw
|
|
}
|
|
return t.write(msg)
|
|
}
|
|
|
|
// write marshals a JSON-RPC message and appends a newline.
|
|
func (t *transport) write(msg *jsonRPCMessage) error {
|
|
data, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
_, err = t.stdin.Write(data)
|
|
return err
|
|
}
|
|
|
|
// Close shuts down the transport by closing the subscription. This causes the
|
|
// read loop to exit and the dispatch loop to follow.
|
|
func (t *transport) Close() error {
|
|
t.closeOnce.Do(func() {
|
|
_ = t.sub.Close()
|
|
})
|
|
return nil
|
|
}
|