This repository has been archived on 2026-07-17. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
codespace/internal/acp/transport.go
T
tao.chen 031451fe3e feat(acp): streaming WebSocket route for prompt chunks
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
2026-07-06 16:43:17 +08:00

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
}