From eb2bf3e6846370da826133b0ae1633ba10453dda Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:52:22 +0800 Subject: [PATCH] fix(acp): parse session/update content as ContentBlock, not string The ACP spec defines session/update content as a ContentBlock object ({type, text}) but we decoded it as a string. This dropped every agent_message_chunk and left prompt responses empty. E2E against the real opencode acp binary surfaced the bug. - internal/acp/messages.go: add ContentBlock struct; Update.Content is now ContentBlock. - internal/acp/client.go: handleNotification extracts Text when Content.Type == "text"; other types are logged at debug and dropped. - internal/acp/client_test.go: NDJSON framing test sends content as a ContentBlock object and reads .Text. - README.md: document /acp/* routes + CODESPACE_OPENCODE_ARGS env. Verified end-to-end: POST /acp/prompt returns text="Hi there!", 4 messages in history (1 user + 3 agent chunks), 0 invalid session/update log entries. Conversation: 019f357d-4236-7010-949c-e61067618d42 --- README.md | 27 ++++++++++++++++++++++++++- internal/acp/client.go | 12 ++++++++++-- internal/acp/client_test.go | 6 +++--- internal/acp/messages.go | 8 +++++++- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1e6b2cf..aba6fdd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ A Go backend skeleton for managing local workspaces and file operations with pro - File read/write/list/mkdir/remove/rename/stat through a `FileSystem` interface with path-escape protection. - Workspace root deletion protection — cannot remove the workspace root directory through the file API. - Configurable per-file write size limit — oversized writes are rejected early before hitting disk. -- OpenCode process start/stop/restart/status per workspace. +- OpenCode process start/stop/restart/status per workspace (launched with `opencode acp` by default; raw stdio is still piped through the process WebSocket for backward compatibility). +- **ACP (Agent Client Protocol) client** — minimal JSON-RPC over NDJSON against the running opencode process. Exposes `initialize` / `session/new` / `session/prompt` / `session/cancel`. Serves `fs/read_text_file` and `fs/write_text_file` from the workspace filesystem. The web UI surfaces this as a chat panel alongside the terminal. - HTTP API using [Gin](https://github.com/gin-gonic/gin) for routing with JSON responses. - Explicit `http.Server` composition with configurable timeouts and max header bytes. - Structured logging using Go standard library `log/slog` with JSON/text format and level control. @@ -44,6 +45,8 @@ workspace: root: "./workspaces" process: opencodeCommand: "opencode" + args: + - "acp" shell: command: "bash" @@ -66,6 +69,7 @@ log: | `CODESPACE_ADDR` | Listen address | `:8080` | | `CODESPACE_WORKSPACE_ROOT` | Workspace storage root | `./workspaces` | | `CODESPACE_OPENCODE_COMMAND` | OpenCode binary path | `opencode` | +| `CODESPACE_OPENCODE_ARGS` | Comma-separated extra args passed to OpenCode (sets `process.args`) | `acp` | | `CODESPACE_SHELL_COMMAND` | Shell binary path | `bash` | | `CODESPACE_SHELL_ARGS` | Comma-separated shell arguments | `-i` | | `CODESPACE_FILE_MAX_WRITE_BYTES` | Max bytes per file write | `1048576` | @@ -125,6 +129,19 @@ All runtime logging uses slog; `fmt`, `log.Printf`, and the Gin default logger a | `GET` | `/api/workspaces/:id/shell/status` | Get shell running status and PID | | `GET` | `/api/workspaces/:id/shell/ws` | WebSocket: stream shell stdout/stderr, send stdin (text frames, raw bytes) | +### ACP (Agent Client Protocol) + +Sits on top of the OpenCode process started above. The ACP service starts the process on the first prompt if it isn't already running, performs the `initialize` + `session/new` handshake, and accumulates the agent's responses until the `session/prompt` reply arrives. + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/workspaces/:id/acp/status` | Observational status: `{workspaceId, ready, sessionId, running, pid, error}`. Does **not** start the process. | +| `GET` | `/api/workspaces/:id/acp/history` | Accumulated conversation: `{sessionId, messages: [{role, text, time}]}`, oldest first. In-memory only — cleared when the process restarts. | +| `POST` | `/api/workspaces/:id/acp/prompt` | Body `{"content": "..."}`. Response `{"sessionId, stopReason, text}`. Starts the process + session on demand. Synchronous (waits up to 5 min). | +| `POST` | `/api/workspaces/:id/acp/cancel` | Best-effort interrupt of an in-flight prompt. | + +`terminal/*` and other unsupported agent-initiated requests return JSON-RPC `Method not found` (`-32601`). + ### Health | Method | Path | Description | @@ -173,6 +190,14 @@ curl -s 'http://localhost:8080/api/workspaces/user1/files/read?path=main.go' curl -s 'http://localhost:8080/api/workspaces/user1/files/stat?path=main.go' ``` +### Send a prompt to the agent + +```sh +curl -s -X POST http://localhost:8080/api/workspaces/user1/acp/prompt \ + -H 'Content-Type: application/json' \ + -d '{"content":"list the files in this workspace"}' +``` + ## Test ```sh diff --git a/internal/acp/client.go b/internal/acp/client.go index df531ee..8388bf4 100644 --- a/internal/acp/client.go +++ b/internal/acp/client.go @@ -237,9 +237,17 @@ func (c *Client) handleNotification(method string, params json.RawMessage) { } switch up.Update.SessionUpdate { case "agent_message_chunk": - c.history.Add("agent", up.Update.Content) + if up.Update.Content.Type == "text" { + c.history.Add("agent", up.Update.Content.Text) + } else { + c.lg.Debug("acp drop non-text agent chunk", "workspace_id", c.workspaceID, "type", up.Update.Content.Type) + } case "user_message_chunk": - c.history.Add("user", up.Update.Content) + if up.Update.Content.Type == "text" { + c.history.Add("user", up.Update.Content.Text) + } else { + c.lg.Debug("acp drop non-text user chunk", "workspace_id", c.workspaceID, "type", up.Update.Content.Type) + } default: c.lg.Debug("acp drop update", "workspace_id", c.workspaceID, "type", up.Update.SessionUpdate) } diff --git a/internal/acp/client_test.go b/internal/acp/client_test.go index 5ad796c..ef51077 100644 --- a/internal/acp/client_test.go +++ b/internal/acp/client_test.go @@ -115,11 +115,11 @@ func TestNDJSONFramingHandlesSplitLines(t *testing.T) { got <- struct { method string content string - }{method, up.Update.Content} + }{method, up.Update.Content.Text} } - line1 := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":"hello"}}}` - line2 := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":" world"}}}` + line1 := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hello"}}}}` + line2 := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" world"}}}}` // Send one complete line plus a partial second line. sub.send([]byte(line1 + "\n" + line2[:len(line2)-2])) diff --git a/internal/acp/messages.go b/internal/acp/messages.go index 5a0d08d..0c8b62c 100644 --- a/internal/acp/messages.go +++ b/internal/acp/messages.go @@ -93,10 +93,16 @@ type SessionUpdateParams struct { type Update struct { SessionUpdate string `json:"sessionUpdate"` MessageID string `json:"messageId,omitempty"` - Content string `json:"content,omitempty"` + Content ContentBlock `json:"content,omitempty"` ToolCallID string `json:"toolCallId,omitempty"` } +// ContentBlock is a content payload in session/update notifications. +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + // FSReadParams is sent by the agent to read a workspace file. type FSReadParams struct { Path string `json:"path"`