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 2597d0d60c feat(backend): add opencode ACP (Agent Client Protocol) client
Adds a minimal but real ACP stack for the opencode process:

- pkg/config: process.args default ["acp"] (opencodeCommand still "opencode")
- internal/process: NewManager(command, args) — exec.Command uses args
- internal/acp (new): NDJSON transport + JSON-RPC client over the existing
  process stdio. Implements initialize / session/new / session/prompt /
  session/cancel. Serves fs/read_text_file and fs/write_text_file from the
  workspace's fs.FileSystem. terminal/* requests get MethodNotFound.
- internal/service/acp_service: per-workspace Client + mutex; starts the
  process on first prompt; transparently re-init on restart.
- internal/api/acp_handler: GET /acp/status, GET /acp/history,
  POST /acp/prompt, POST /acp/cancel.
- internal/model/acp: API DTOs.
- internal/acp/client_test: NDJSON split-lines, request/response correlation,
  notification dispatch, agent-initiated request handling (fs + terminal).

Existing process WS endpoint and Shell subsystem are unchanged.

Conversation: 019f354c-a51b-7ec3-83ad-c647e9b50b19
2026-07-06 11:03:59 +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 {
go 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
}