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
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Structured logging using Go standard library `log/slog` with JSON/text format and level control.
|
||||||
@@ -44,6 +45,8 @@ workspace:
|
|||||||
root: "./workspaces"
|
root: "./workspaces"
|
||||||
process:
|
process:
|
||||||
opencodeCommand: "opencode"
|
opencodeCommand: "opencode"
|
||||||
|
args:
|
||||||
|
- "acp"
|
||||||
|
|
||||||
shell:
|
shell:
|
||||||
command: "bash"
|
command: "bash"
|
||||||
@@ -66,6 +69,7 @@ log:
|
|||||||
| `CODESPACE_ADDR` | Listen address | `:8080` |
|
| `CODESPACE_ADDR` | Listen address | `:8080` |
|
||||||
| `CODESPACE_WORKSPACE_ROOT` | Workspace storage root | `./workspaces` |
|
| `CODESPACE_WORKSPACE_ROOT` | Workspace storage root | `./workspaces` |
|
||||||
| `CODESPACE_OPENCODE_COMMAND` | OpenCode binary path | `opencode` |
|
| `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_COMMAND` | Shell binary path | `bash` |
|
||||||
| `CODESPACE_SHELL_ARGS` | Comma-separated shell arguments | `-i` |
|
| `CODESPACE_SHELL_ARGS` | Comma-separated shell arguments | `-i` |
|
||||||
| `CODESPACE_FILE_MAX_WRITE_BYTES` | Max bytes per file write | `1048576` |
|
| `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/status` | Get shell running status and PID |
|
||||||
| `GET` | `/api/workspaces/:id/shell/ws` | WebSocket: stream shell stdout/stderr, send stdin (text frames, raw bytes) |
|
| `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
|
### Health
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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'
|
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
|
## Test
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+10
-2
@@ -237,9 +237,17 @@ func (c *Client) handleNotification(method string, params json.RawMessage) {
|
|||||||
}
|
}
|
||||||
switch up.Update.SessionUpdate {
|
switch up.Update.SessionUpdate {
|
||||||
case "agent_message_chunk":
|
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":
|
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:
|
default:
|
||||||
c.lg.Debug("acp drop update", "workspace_id", c.workspaceID, "type", up.Update.SessionUpdate)
|
c.lg.Debug("acp drop update", "workspace_id", c.workspaceID, "type", up.Update.SessionUpdate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,11 +115,11 @@ func TestNDJSONFramingHandlesSplitLines(t *testing.T) {
|
|||||||
got <- struct {
|
got <- struct {
|
||||||
method string
|
method string
|
||||||
content 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"}}}`
|
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":" world"}}}`
|
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.
|
// Send one complete line plus a partial second line.
|
||||||
sub.send([]byte(line1 + "\n" + line2[:len(line2)-2]))
|
sub.send([]byte(line1 + "\n" + line2[:len(line2)-2]))
|
||||||
|
|||||||
@@ -93,10 +93,16 @@ type SessionUpdateParams struct {
|
|||||||
type Update struct {
|
type Update struct {
|
||||||
SessionUpdate string `json:"sessionUpdate"`
|
SessionUpdate string `json:"sessionUpdate"`
|
||||||
MessageID string `json:"messageId,omitempty"`
|
MessageID string `json:"messageId,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content ContentBlock `json:"content,omitempty"`
|
||||||
ToolCallID string `json:"toolCallId,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.
|
// FSReadParams is sent by the agent to read a workspace file.
|
||||||
type FSReadParams struct {
|
type FSReadParams struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
|||||||
Reference in New Issue
Block a user