feat(acp): add stdio JSON-RPC adapter layer (phase A)

Faithful Go port of main's src/acp/*.ts: NDJSON JSON-RPC 2.0 server,
ACP v1 wire types, event.Sink -> session/update dispatcher,
permission.Approver -> session/request_permission bridge, and a session
registry behind a Factory seam so the package stays independent of cli
wiring. Tested: lifecycle, permission round-trip, cancel, framing.
This commit is contained in:
esengine committed 2026-05-29 20:29:39 -07:00
1 parent 4dad578f2e
commit 5d40158aa6
8 files changed
+1716

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
package acp
import (
"context"
"encoding/json"
"strconv"
"strings"
"reasonix/internal/event"
)
// notifier is the slice of Conn that the per-session adapters depend on: the
// dispatch sink pushes session/update notifications, the approver makes a
// session/request_permission request. Narrowing to this interface keeps both
// unit-testable with a fake.
type notifier interface {
Notify(method string, params any) error
Request(ctx context.Context, method string, params any) (json.RawMessage, error)
}
// maxResultChars clips a tool result before it crosses the wire, matching main's
// dispatch.ts (the full result still goes to the model; this is display only).
const maxResultChars = 8000
// updateSink is an event.Sink bound to one session that maps the agent's typed
// event stream onto ACP session/update notifications. It is the v2 counterpart of
// main's dispatchKernelEvent: where main translated kernel events, we translate
// the event.Event the v2 agent already emits.
//
// v2 has no separate "tool intent" event — a call goes ToolDispatch → ToolResult,
// two states — so we emit a single pending tool_call on dispatch (already carrying
// rawInput, which main only had by the intent step) and a completed/failed
// tool_call_update on result. Message/Usage/Phase/TurnStarted have no place in
// main's update set and are dropped.
type updateSink struct {
conn notifier
sessionID string
}
func newUpdateSink(conn notifier, sessionID string) *updateSink {
return &updateSink{conn: conn, sessionID: sessionID}
}
// Emit implements event.Sink. The agent calls it serially (see event.Sink), so no
// locking is needed; write serialization lives in Conn.
func (s *updateSink) Emit(e event.Event) {
switch e.Kind {
case event.Reasoning:
if e.Text == "" {
return
}
s.send(messageChunk{SessionUpdate: "agent_thought_chunk", Content: textBlock(e.Text)})
case event.Text:
if e.Text == "" {
return
}
s.send(messageChunk{SessionUpdate: "agent_message_chunk", Content: textBlock(e.Text)})
case event.ToolDispatch:
s.send(toolCall{
SessionUpdate: "tool_call",
ToolCallID: e.Tool.ID,
Title: e.Tool.Name,
Kind: toolKindFor(e.Tool.Name),
Status: "pending",
RawInput: rawJSON(e.Tool.Args),
})
case event.ToolResult:
status := "completed"
text := e.Tool.Output
if e.Tool.Err != "" {
status = "failed"
text = e.Tool.Err
}
s.send(toolCallUpdateMsg{
SessionUpdate: "tool_call_update",
ToolCallID: e.Tool.ID,
Status: status,
Content: []toolContent{{Type: "content", Content: textBlock(clip(text))}},
})
case event.Notice:
// Surface warnings to the host as a message chunk so they're not lost;
// info-level notices stay out of band.
if e.Level == event.LevelWarn && e.Text != "" {
s.send(messageChunk{
SessionUpdate: "agent_message_chunk",
Content: textBlock("\n\n[warning] " + e.Text),
})
}
}
}
func (s *updateSink) send(update any) {
_ = s.conn.Notify("session/update", SessionUpdateParams{SessionID: s.sessionID, Update: update})
}
// textBlock builds a text content block.
func textBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
// rawJSON returns args as a raw JSON value when it is valid JSON, else nil so the
// rawInput field is omitted rather than carrying a malformed payload.
func rawJSON(args string) json.RawMessage {
if args == "" || !json.Valid([]byte(args)) {
return nil
}
return json.RawMessage(args)
}
// clip truncates text to maxResultChars, appending a note, matching dispatch.ts.
func clip(text string) string {
if len(text) <= maxResultChars {
return text
}
return text[:maxResultChars] + "\n…(" +
strconv.Itoa(len(text)-maxResultChars) + " more chars truncated)"
}
// toolKindFor maps a tool name to the ACP tool kind the host uses to categorize
// the call in its UI. The kinds match main's restricted set
// (read/edit/search/execute/other). Known v2 built-ins map explicitly; anything
// else (plugins, the task tool) falls back to a name heuristic, then "other".
func toolKindFor(name string) string {
switch name {
case "read_file", "ls", "glob":
return "read"
case "grep":
return "search"
case "edit_file", "multiedit", "write_file":
return "edit"
case "bash":
return "execute"
}
n := strings.ToLower(name)
switch {
case strings.Contains(n, "search") || strings.Contains(n, "grep") || strings.Contains(n, "find"):
return "search"
case strings.Contains(n, "edit") || strings.Contains(n, "write") || strings.Contains(n, "replace"):
return "edit"
case strings.Contains(n, "read") || strings.Contains(n, "cat") || strings.Contains(n, "view"):
return "read"
case strings.Contains(n, "bash") || strings.Contains(n, "exec") || strings.Contains(n, "shell") || strings.Contains(n, "run"):
return "execute"
default:
return "other"
}
}
+190
View File
@@ -0,0 +1,190 @@
package acp
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"reasonix/internal/event"
)
// fakeNotifier captures Notify calls and answers Request via an injectable hook,
// standing in for *Conn in adapter unit tests.
type fakeNotifier struct {
mu sync.Mutex
notifs []capturedNotif
onReq func(method string, params any) (json.RawMessage, error)
reqSeen []capturedNotif
}
type capturedNotif struct {
method string
params any
}
func (f *fakeNotifier) Notify(method string, params any) error {
f.mu.Lock()
defer f.mu.Unlock()
f.notifs = append(f.notifs, capturedNotif{method, params})
return nil
}
func (f *fakeNotifier) Request(_ context.Context, method string, params any) (json.RawMessage, error) {
f.mu.Lock()
f.reqSeen = append(f.reqSeen, capturedNotif{method, params})
f.mu.Unlock()
if f.onReq != nil {
return f.onReq(method, params)
}
return nil, nil
}
// updateMap marshals the i-th captured notification's params and decodes the
// nested "update" object into a generic map for shape assertions.
func (f *fakeNotifier) updateMap(t *testing.T, i int) map[string]any {
t.Helper()
f.mu.Lock()
defer f.mu.Unlock()
if i >= len(f.notifs) {
t.Fatalf("only %d notifications captured, wanted index %d", len(f.notifs), i)
}
n := f.notifs[i]
if n.method != "session/update" {
t.Fatalf("notif %d method = %q, want session/update", i, n.method)
}
raw, err := json.Marshal(n.params)
if err != nil {
t.Fatalf("marshal params: %v", err)
}
var decoded struct {
SessionID string `json:"sessionId"`
Update map[string]any `json:"update"`
}
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal params: %v", err)
}
if decoded.SessionID != "sess-1" {
t.Errorf("notif %d sessionId = %q, want sess-1", i, decoded.SessionID)
}
return decoded.Update
}
func TestUpdateSinkMapsEvents(t *testing.T) {
fn := &fakeNotifier{}
sink := newUpdateSink(fn, "sess-1")
sink.Emit(event.Event{Kind: event.Reasoning, Text: "thinking..."})
sink.Emit(event.Event{Kind: event.Text, Text: "answer"})
sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{
ID: "call-1", Name: "read_file", Args: `{"path":"a.go"}`, ReadOnly: true,
}})
sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{
ID: "call-1", Name: "read_file", Output: "package main",
}})
sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{
ID: "call-2", Name: "bash", Err: "permission denied",
}})
if got := len(fn.notifs); got != 5 {
t.Fatalf("emitted %d notifications, want 5", got)
}
// agent_thought_chunk
u := fn.updateMap(t, 0)
if u["sessionUpdate"] != "agent_thought_chunk" {
t.Errorf("update 0 = %v, want agent_thought_chunk", u["sessionUpdate"])
}
if content, _ := u["content"].(map[string]any); content["text"] != "thinking..." {
t.Errorf("update 0 content text = %v", content)
}
// agent_message_chunk
u = fn.updateMap(t, 1)
if u["sessionUpdate"] != "agent_message_chunk" {
t.Errorf("update 1 = %v, want agent_message_chunk", u["sessionUpdate"])
}
// tool_call (pending, with kind + rawInput)
u = fn.updateMap(t, 2)
if u["sessionUpdate"] != "tool_call" || u["status"] != "pending" {
t.Errorf("update 2 = %v", u)
}
if u["kind"] != "read" {
t.Errorf("update 2 kind = %v, want read", u["kind"])
}
if u["toolCallId"] != "call-1" {
t.Errorf("update 2 toolCallId = %v, want call-1", u["toolCallId"])
}
if ri, _ := u["rawInput"].(map[string]any); ri["path"] != "a.go" {
t.Errorf("update 2 rawInput = %v", u["rawInput"])
}
// tool_call_update completed
u = fn.updateMap(t, 3)
if u["sessionUpdate"] != "tool_call_update" || u["status"] != "completed" {
t.Errorf("update 3 = %v", u)
}
// tool_call_update failed surfaces the error text
u = fn.updateMap(t, 4)
if u["status"] != "failed" {
t.Errorf("update 4 status = %v, want failed", u["status"])
}
arr, _ := u["content"].([]any)
if len(arr) != 1 {
t.Fatalf("update 4 content = %v", u["content"])
}
wrap, _ := arr[0].(map[string]any)
inner, _ := wrap["content"].(map[string]any)
if inner["text"] != "permission denied" {
t.Errorf("update 4 inner text = %v, want permission denied", inner["text"])
}
}
func TestUpdateSinkDropsAndWarns(t *testing.T) {
fn := &fakeNotifier{}
sink := newUpdateSink(fn, "sess-1")
// Dropped kinds: TurnStarted, Message, Usage, Phase, and empty deltas.
sink.Emit(event.Event{Kind: event.TurnStarted})
sink.Emit(event.Event{Kind: event.Message, Text: "full", Reasoning: "chain"})
sink.Emit(event.Event{Kind: event.Usage})
sink.Emit(event.Event{Kind: event.Phase, Text: "planning"})
sink.Emit(event.Event{Kind: event.Text, Text: ""})
if got := len(fn.notifs); got != 0 {
t.Fatalf("dropped kinds produced %d notifications, want 0", got)
}
// Warn-level notices are surfaced as a message chunk; info notices are not.
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "fyi"})
if got := len(fn.notifs); got != 0 {
t.Fatalf("info notice produced %d notifications, want 0", got)
}
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: "watch out"})
if got := len(fn.notifs); got != 1 {
t.Fatalf("warn notice produced %d notifications, want 1", got)
}
u := fn.updateMap(t, 0)
if u["sessionUpdate"] != "agent_message_chunk" {
t.Errorf("warn update = %v", u["sessionUpdate"])
}
if c, _ := u["content"].(map[string]any); !strings.Contains(c["text"].(string), "watch out") {
t.Errorf("warn content = %v", u["content"])
}
}
func TestClip(t *testing.T) {
if got := clip("short"); got != "short" {
t.Errorf("clip(short) = %q", got)
}
long := strings.Repeat("x", maxResultChars+10)
got := clip(long)
if !strings.HasPrefix(got, strings.Repeat("x", maxResultChars)) {
t.Errorf("clip did not preserve the head")
}
if !strings.Contains(got, "10 more chars truncated") {
t.Errorf("clip note missing: %q", got[len(got)-40:])
}
}
+84
View File
@@ -0,0 +1,84 @@
package acp
import (
"context"
"encoding/json"
"strconv"
"sync/atomic"
)
// rpcApprover bridges the v2 permission.Approver contract onto an ACP
// session/request_permission round-trip. It is the counterpart of main's
// gates.ts: the agent's interactive Gate calls Approve when a tool needs the
// user's say-so, and we forward that as a permission request to the host client,
// blocking on its reply.
//
// It implements permission.Approver structurally (same Approve signature), so the
// composition root wraps the session policy with permission.NewGate(policy,
// approver) without this package importing the permission package.
type rpcApprover struct {
conn notifier
sessionID string
seq atomic.Int64
}
func newRPCApprover(conn notifier, sessionID string) *rpcApprover {
return &rpcApprover{conn: conn, sessionID: sessionID}
}
// Approve asks the host to allow a pending call. It returns whether to allow it
// and whether to remember the choice (mapped from the allow_always option). A
// cancelled or rejected outcome denies the call; a context error (the turn was
// cancelled while waiting) aborts the turn. This matches permission.Approver.
func (a *rpcApprover) Approve(ctx context.Context, toolName, subject string, args json.RawMessage) (allow, remember bool, err error) {
id := "gate-" + toolName + "-" + strconv.FormatInt(a.seq.Add(1), 10)
title := toolName
if subject != "" {
title = toolName + " " + subject
}
params := PermissionRequestParams{
SessionID: a.sessionID,
ToolCall: PermissionToolCall{
ToolCallID: id,
Title: title,
Kind: toolKindFor(toolName),
Status: "pending",
RawInput: rawJSON(string(args)),
},
Options: []PermissionOption{
{OptionID: string(OptAllowOnce), Name: "Allow", Kind: OptAllowOnce},
{OptionID: string(OptAllowAlways), Name: "Always allow", Kind: OptAllowAlways},
{OptionID: string(OptRejectOnce), Name: "Reject", Kind: OptRejectOnce},
},
}
raw, err := a.conn.Request(ctx, "session/request_permission", params)
if err != nil {
// A cancelled turn must abort (surface the ctx error); any other transport
// failure is treated as a denial so the model gets a blocked result rather
// than the turn dying.
if ctx.Err() != nil {
return false, false, ctx.Err()
}
return false, false, nil
}
var res PermissionRequestResult
if err := json.Unmarshal(raw, &res); err != nil {
return false, false, nil
}
if res.Outcome.Outcome != "selected" {
return false, false, nil // "cancelled"
}
switch PermissionOptionKind(res.Outcome.OptionID) {
case OptAllowOnce:
return true, false, nil
case OptAllowAlways:
return true, true, nil
case OptRejectOnce, OptRejectAlways:
return false, false, nil
default:
return true, false, nil // unknown but selected → allow once
}
}
+274
View File
@@ -0,0 +1,274 @@
// Package acp implements the Agent Client Protocol (https://agentclientprotocol.com)
// transport: a stdio JSON-RPC 2.0 agent that editors and other host clients speak
// to drive Reasonix. Many tools integrated with the v1 (main-branch) agent over
// ACP, so v2 keeps the wire contract identical — the wire types in this file are a
// faithful port of main's src/acp/protocol.ts (ACP protocol version 1).
//
// The package is an adapter layer over the v2 kernel and depends only on stable
// contracts: it maps the agent's typed event.Event stream onto session/update
// notifications (see dispatch.go), bridges permission.Approver onto
// session/request_permission round-trips (see permission.go), and exposes the
// whole thing over NDJSON JSON-RPC (see server.go). How a per-session agent is
// actually assembled — provider, tools rooted at the session cwd, per-session MCP
// — is left to a Factory the composition root supplies (see service.go), so this
// package stays independent of the cli wiring.
package acp
import (
"encoding/json"
"strings"
)
// ProtocolVersion is the ACP version this agent implements. Matches main.
const ProtocolVersion = 1
// JSON-RPC 2.0 error codes (subset used on the wire). Mirrors protocol.ts.
const (
ErrParse = -32700
ErrInvalidRequest = -32600
ErrMethodNotFound = -32601
ErrInvalidParams = -32602
ErrInternal = -32603
)
// --- initialize ---
// InitializeParams is the client's handshake. We accept and ignore its
// capabilities/info — the agent advertises a fixed capability set in reply.
type InitializeParams struct {
ProtocolVersion int `json:"protocolVersion"`
ClientInfo *Implementation `json:"clientInfo,omitempty"`
}
// Implementation names a participant (client or agent) on the wire.
type Implementation struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
Version string `json:"version,omitempty"`
}
// InitializeResult advertises what this agent supports. The capability flags
// match main exactly: sessions are created via session/new (no loadSession),
// prompts may carry inline resource text (embeddedContext) but not image/audio,
// and MCP is stdio-only (no http/sse).
type InitializeResult struct {
ProtocolVersion int `json:"protocolVersion"`
AgentCapabilities AgentCapabilities `json:"agentCapabilities"`
AgentInfo Implementation `json:"agentInfo"`
AuthMethods []any `json:"authMethods"`
}
// AgentCapabilities is the agentCapabilities object in InitializeResult.
type AgentCapabilities struct {
LoadSession bool `json:"loadSession"`
PromptCapabilities PromptCapabilities `json:"promptCapabilities"`
MCPCapabilities MCPCapabilities `json:"mcpCapabilities"`
}
// PromptCapabilities reports which content-block kinds prompts may carry.
type PromptCapabilities struct {
Image bool `json:"image"`
Audio bool `json:"audio"`
EmbeddedContext bool `json:"embeddedContext"`
}
// MCPCapabilities reports which MCP transports session/new accepts.
type MCPCapabilities struct {
HTTP bool `json:"http"`
SSE bool `json:"sse"`
}
// --- session/new ---
// SessionNewParams opens a session rooted at cwd, optionally with stdio MCP
// servers the agent should connect for the session's lifetime.
type SessionNewParams struct {
Cwd string `json:"cwd,omitempty"`
MCPServers []MCPServerSpec `json:"mcpServers,omitempty"`
}
// MCPServerSpec describes one stdio MCP server the client asks the agent to run.
type MCPServerSpec struct {
Name string `json:"name"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
}
// SessionNewResult returns the opaque id used to address the session thereafter.
type SessionNewResult struct {
SessionID string `json:"sessionId"`
}
// --- content blocks (inbound prompt) ---
// ContentBlock is one piece of a prompt. The agent reads text blocks and the
// inline text of resource blocks (embeddedContext); image/audio are accepted on
// the wire but ignored, matching the advertised capabilities.
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Resource *ResourceContents `json:"resource,omitempty"`
MimeType string `json:"mimeType,omitempty"`
Data string `json:"data,omitempty"`
}
// ResourceContents is the embedded resource of a "resource" content block.
type ResourceContents struct {
URI string `json:"uri"`
MimeType string `json:"mimeType,omitempty"`
Text string `json:"text,omitempty"`
}
// FlattenPrompt extracts the user-visible prompt text out of ACP content blocks.
// Text blocks contribute their text; resource blocks contribute their inline
// text when present (embeddedContext). Other block kinds are dropped. Ported from
// protocol.ts flattenPrompt.
func FlattenPrompt(blocks []ContentBlock) string {
parts := make([]string, 0, len(blocks))
for _, b := range blocks {
switch b.Type {
case "text":
if b.Text != "" {
parts = append(parts, b.Text)
}
case "resource":
if b.Resource != nil && b.Resource.Text != "" {
parts = append(parts, b.Resource.Text)
}
}
}
return strings.TrimSpace(strings.Join(parts, "\n\n"))
}
// --- session/prompt ---
// SessionPromptParams sends a turn's prompt to a session.
type SessionPromptParams struct {
SessionID string `json:"sessionId"`
Prompt []ContentBlock `json:"prompt"`
}
// StopReason tells the client why a turn ended. Values match main's wire.
type StopReason string
const (
StopEndTurn StopReason = "end_turn"
StopCancelled StopReason = "cancelled"
StopError StopReason = "error"
)
// SessionPromptResult ends a session/prompt. TranscriptPath is reserved for a
// future on-disk transcript pointer; omitted (null) for now.
type SessionPromptResult struct {
StopReason StopReason `json:"stopReason"`
TranscriptPath *string `json:"transcriptPath,omitempty"`
}
// --- session/update (agent → client notifications) ---
//
// SessionUpdate is a tagged union discriminated by sessionUpdate. The variants
// reuse the JSON key "content" with two incompatible shapes (a single block for
// message chunks, an array for tool results), so we model each variant as its own
// struct rather than one struct with conflicting tags, and carry it through
// SessionUpdateParams.Update as an interface value.
// SessionUpdateParams wraps one update for a session.
type SessionUpdateParams struct {
SessionID string `json:"sessionId"`
Update any `json:"update"`
}
// messageChunk is agent_message_chunk / agent_thought_chunk.
type messageChunk struct {
SessionUpdate string `json:"sessionUpdate"`
Content ContentBlock `json:"content"`
Metadata *updateMeta `json:"metadata,omitempty"`
}
// updateMeta carries optional error detail on an agent_message_chunk.
type updateMeta struct {
Error *updateError `json:"error,omitempty"`
}
type updateError struct {
Name string `json:"name"`
Message string `json:"message"`
}
// toolCall is a "tool_call" update (announces a call, with title/kind/rawInput).
type toolCall struct {
SessionUpdate string `json:"sessionUpdate"`
ToolCallID string `json:"toolCallId"`
Title string `json:"title,omitempty"`
Kind string `json:"kind,omitempty"`
Status string `json:"status,omitempty"`
RawInput json.RawMessage `json:"rawInput,omitempty"`
}
// toolCallUpdateMsg is a "tool_call_update" update (status + result content).
type toolCallUpdateMsg struct {
SessionUpdate string `json:"sessionUpdate"`
ToolCallID string `json:"toolCallId"`
Status string `json:"status,omitempty"`
Content []toolContent `json:"content,omitempty"`
}
// toolContent wraps a tool result's text, per the ACP tool_call_update shape.
type toolContent struct {
Type string `json:"type"`
Content ContentBlock `json:"content"`
}
// --- session/cancel (client → agent notification) ---
// SessionCancelParams cancels an in-progress turn.
type SessionCancelParams struct {
SessionID string `json:"sessionId"`
}
// --- session/request_permission (agent → client request) ---
// PermissionOptionKind classifies an option for host UI styling. Matches main.
type PermissionOptionKind string
const (
OptAllowOnce PermissionOptionKind = "allow_once"
OptAllowAlways PermissionOptionKind = "allow_always"
OptRejectOnce PermissionOptionKind = "reject_once"
OptRejectAlways PermissionOptionKind = "reject_always"
)
// PermissionOption is one choice offered to the user for a permission request.
type PermissionOption struct {
OptionID string `json:"optionId"`
Name string `json:"name"`
Kind PermissionOptionKind `json:"kind"`
}
// PermissionRequestParams asks the client to approve a pending tool call.
type PermissionRequestParams struct {
SessionID string `json:"sessionId"`
ToolCall PermissionToolCall `json:"toolCall"`
Options []PermissionOption `json:"options"`
}
// PermissionToolCall describes the call awaiting approval.
type PermissionToolCall struct {
ToolCallID string `json:"toolCallId"`
Title string `json:"title,omitempty"`
Kind string `json:"kind,omitempty"`
Status string `json:"status,omitempty"`
RawInput json.RawMessage `json:"rawInput,omitempty"`
}
// PermissionRequestResult is the client's reply to a permission request.
type PermissionRequestResult struct {
Outcome PermissionOutcome `json:"outcome"`
}
// PermissionOutcome is "selected" (with optionId) or "cancelled".
type PermissionOutcome struct {
Outcome string `json:"outcome"`
OptionID string `json:"optionId,omitempty"`
}
+83
View File
@@ -0,0 +1,83 @@
package acp
import "testing"
func TestFlattenPrompt(t *testing.T) {
tests := []struct {
name string
blocks []ContentBlock
want string
}{
{
name: "text blocks join with blank line",
blocks: []ContentBlock{{Type: "text", Text: "hello"}, {Type: "text", Text: "world"}},
want: "hello\n\nworld",
},
{
name: "resource contributes inline text",
blocks: []ContentBlock{
{Type: "text", Text: "see file:"},
{Type: "resource", Resource: &ResourceContents{URI: "file:///x", Text: "contents"}},
},
want: "see file:\n\ncontents",
},
{
name: "resource without inline text is dropped",
blocks: []ContentBlock{
{Type: "resource", Resource: &ResourceContents{URI: "file:///x"}},
{Type: "text", Text: "only this"},
},
want: "only this",
},
{
name: "image and audio blocks are ignored",
blocks: []ContentBlock{
{Type: "image", MimeType: "image/png", Data: "base64"},
{Type: "text", Text: "kept"},
{Type: "audio", MimeType: "audio/wav", Data: "base64"},
},
want: "kept",
},
{
name: "surrounding whitespace trimmed",
blocks: []ContentBlock{{Type: "text", Text: " spaced "}},
want: "spaced",
},
{
name: "empty input",
blocks: nil,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FlattenPrompt(tt.blocks); got != tt.want {
t.Errorf("FlattenPrompt() = %q, want %q", got, tt.want)
}
})
}
}
func TestToolKindFor(t *testing.T) {
tests := map[string]string{
"read_file": "read",
"ls": "read",
"glob": "read",
"grep": "search",
"edit_file": "edit",
"multiedit": "edit",
"write_file": "edit",
"bash": "execute",
"webfetch": "other",
"task": "other",
"mcp__server__do_thing": "other",
"semantic_search": "search", // heuristic fallback
"run_command": "execute",
"unknown": "other",
}
for name, want := range tests {
if got := toolKindFor(name); got != want {
t.Errorf("toolKindFor(%q) = %q, want %q", name, got, want)
}
}
}
+311
View File
@@ -0,0 +1,311 @@
package acp
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"strconv"
"sync"
"sync/atomic"
)
// maxMessageBytes caps a single inbound NDJSON line. ACP messages can embed
// resource text, so the limit is generous; a line past it is a framing error.
const maxMessageBytes = 32 << 20 // 32 MiB
// RequestHandler answers an inbound JSON-RPC request. The returned value is
// marshaled as the response result. To control the error code, return a
// *RPCError; any other error becomes ErrInternal.
type RequestHandler func(ctx context.Context, params json.RawMessage) (any, error)
// NotificationHandler reacts to an inbound notification. It cannot reply, so it
// returns nothing — errors have nowhere to go on the wire (stderr would corrupt
// stdout, which is the JSON-RPC channel).
type NotificationHandler func(ctx context.Context, params json.RawMessage)
// RPCError lets a handler choose the JSON-RPC error code returned to the client.
type RPCError struct {
Code int
Message string
}
func (e *RPCError) Error() string { return e.Message }
// Conn is one NDJSON JSON-RPC 2.0 connection over a reader/writer pair (stdin/
// stdout in production). It dispatches inbound requests and notifications to
// registered handlers, and can itself send outbound notifications (session/update)
// and requests (session/request_permission), correlating replies by id.
//
// Writes are serialized by a mutex, so handlers running on separate goroutines
// (a long session/prompt alongside a session/cancel) never interleave a line.
// It implements notifier, the dependency dispatch.go and permission.go take.
type Conn struct {
r io.Reader
wmu sync.Mutex
enc *json.Encoder
nextID atomic.Int64
pmu sync.Mutex
pending map[int64]chan rpcResult
reqH map[string]RequestHandler
notH map[string]NotificationHandler
closeOnce sync.Once
closed chan struct{}
}
// rpcResult is the outcome of an outbound request, delivered to the waiter.
type rpcResult struct {
result json.RawMessage
err error
}
// rpcError is the JSON-RPC error object on the wire.
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
// outbound is a JSON-RPC frame we send. omitempty fields select between request,
// notification, success response, and error response shapes.
type outbound struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
// inbound is a parsed JSON-RPC frame we received. The combination of id/method
// presence distinguishes request, notification, and response.
type inbound struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
// NewConn wires a connection over r (inbound) and w (outbound). Register handlers
// with Handle / HandleNotify before calling Serve. The encoder disables HTML
// escaping so payloads match main's JSON.stringify output byte-for-byte.
func NewConn(r io.Reader, w io.Writer) *Conn {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return &Conn{
r: r,
enc: enc,
pending: make(map[int64]chan rpcResult),
reqH: make(map[string]RequestHandler),
notH: make(map[string]NotificationHandler),
closed: make(chan struct{}),
}
}
// Handle registers a request handler for method. Not safe to call concurrently
// with Serve; wire all handlers up first.
func (c *Conn) Handle(method string, h RequestHandler) { c.reqH[method] = h }
// HandleNotify registers a notification handler for method.
func (c *Conn) HandleNotify(method string, h NotificationHandler) { c.notH[method] = h }
// Serve reads inbound frames until the reader ends or ctx is cancelled, then
// fails any in-flight outbound requests. Each inbound request/notification runs
// on its own goroutine so a long-running prompt does not block cancellation or
// permission replies. Returns nil on clean EOF.
func (c *Conn) Serve(ctx context.Context) error {
defer c.shutdown()
br := bufio.NewReaderSize(c.r, 64<<10)
for {
line, err := readLine(br)
if len(line) > 0 {
c.dispatch(ctx, line)
}
if err != nil {
if errors.Is(err, io.EOF) {
return nil
}
return err
}
if ctx.Err() != nil {
return ctx.Err()
}
}
}
// readLine reads one NDJSON line (without the trailing newline), enforcing the
// size cap. It returns the line and any read error; on EOF it still returns the
// trailing partial line so a final newline-less frame is processed.
func readLine(br *bufio.Reader) ([]byte, error) {
var buf []byte
for {
chunk, err := br.ReadSlice('\n')
buf = append(buf, chunk...)
if len(buf) > maxMessageBytes {
return nil, errors.New("acp: message exceeds size limit")
}
if err == bufio.ErrBufferFull {
continue
}
// Trim the trailing newline (and CR) if present.
n := len(buf)
for n > 0 && (buf[n-1] == '\n' || buf[n-1] == '\r') {
n--
}
return trimSpace(buf[:n]), err
}
}
// trimSpace drops leading/trailing ASCII whitespace without allocating.
func trimSpace(b []byte) []byte {
i, j := 0, len(b)
for i < j && isSpace(b[i]) {
i++
}
for j > i && isSpace(b[j-1]) {
j--
}
return b[i:j]
}
func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }
// dispatch parses one frame and routes it. Requests and notifications fan out to
// goroutines; responses resolve inline (they are cheap and need ordering only
// against the pending map, which is mutex-guarded).
func (c *Conn) dispatch(ctx context.Context, line []byte) {
var in inbound
if err := json.Unmarshal(line, &in); err != nil {
c.writeError(json.RawMessage("null"), ErrParse, "parse error")
return
}
hasID := len(in.ID) > 0
switch {
case in.Method != "" && hasID:
go c.serveRequest(ctx, in.ID, in.Method, in.Params)
case in.Method != "" && !hasID:
if h := c.notH[in.Method]; h != nil {
go h(ctx, in.Params)
}
case in.Method == "" && hasID:
c.resolve(in)
default:
c.writeError(json.RawMessage("null"), ErrInvalidRequest, "invalid request")
}
}
// serveRequest runs a request handler and writes its response (or error).
func (c *Conn) serveRequest(ctx context.Context, id json.RawMessage, method string, params json.RawMessage) {
h := c.reqH[method]
if h == nil {
c.writeError(id, ErrMethodNotFound, "method not found: "+method)
return
}
result, err := h(ctx, params)
if err != nil {
code := ErrInternal
var re *RPCError
if errors.As(err, &re) {
code = re.Code
}
c.writeError(id, code, err.Error())
return
}
raw, err := json.Marshal(result)
if err != nil {
c.writeError(id, ErrInternal, "marshal result: "+err.Error())
return
}
_ = c.write(outbound{JSONRPC: "2.0", ID: id, Result: raw})
}
// resolve delivers a response to the goroutine waiting on its outbound request.
func (c *Conn) resolve(in inbound) {
id, err := strconv.ParseInt(string(in.ID), 10, 64)
if err != nil {
return // we only issue integer ids; an unparsable id isn't ours
}
c.pmu.Lock()
ch := c.pending[id]
delete(c.pending, id)
c.pmu.Unlock()
if ch == nil {
return
}
if in.Error != nil {
ch <- rpcResult{err: errors.New(in.Error.Message)}
return
}
ch <- rpcResult{result: in.Result}
}
// Notify sends a fire-and-forget notification. Satisfies notifier.
func (c *Conn) Notify(method string, params any) error {
raw, err := json.Marshal(params)
if err != nil {
return err
}
return c.write(outbound{JSONRPC: "2.0", Method: method, Params: raw})
}
// Request sends an outbound request and blocks until the peer responds, ctx is
// cancelled, or the connection closes. Satisfies notifier.
func (c *Conn) Request(ctx context.Context, method string, params any) (json.RawMessage, error) {
raw, err := json.Marshal(params)
if err != nil {
return nil, err
}
id := c.nextID.Add(1)
ch := make(chan rpcResult, 1)
c.pmu.Lock()
c.pending[id] = ch
c.pmu.Unlock()
defer func() {
c.pmu.Lock()
delete(c.pending, id)
c.pmu.Unlock()
}()
idRaw, _ := json.Marshal(id)
if err := c.write(outbound{JSONRPC: "2.0", ID: idRaw, Method: method, Params: raw}); err != nil {
return nil, err
}
select {
case res := <-ch:
return res.result, res.err
case <-ctx.Done():
return nil, ctx.Err()
case <-c.closed:
return nil, errors.New("acp: connection closed")
}
}
func (c *Conn) write(m outbound) error {
c.wmu.Lock()
defer c.wmu.Unlock()
return c.enc.Encode(m)
}
func (c *Conn) writeError(id json.RawMessage, code int, msg string) {
_ = c.write(outbound{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
}
// shutdown fails every in-flight outbound request so their goroutines unblock.
func (c *Conn) shutdown() {
c.closeOnce.Do(func() {
close(c.closed)
c.pmu.Lock()
for id, ch := range c.pending {
ch <- rpcResult{err: errors.New("acp: connection closed")}
delete(c.pending, id)
}
c.pmu.Unlock()
})
}
+367
View File
@@ -0,0 +1,367 @@
package acp
import (
"context"
"encoding/json"
"fmt"
"io"
"sync"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/permission"
)
// --- fakes: a Factory/Runner pair driven by an injected behavior ---
type fakeRunner struct {
sink event.Sink
approver permission.Approver
behavior func(ctx context.Context, r *fakeRunner, input string) error
}
func (r *fakeRunner) Run(ctx context.Context, input string) error {
return r.behavior(ctx, r, input)
}
type fakeFactory struct {
behavior func(ctx context.Context, r *fakeRunner, input string) error
mu sync.Mutex
params []SessionParams
}
func (f *fakeFactory) NewSession(_ context.Context, p SessionParams) (agent.Runner, func(), error) {
f.mu.Lock()
f.params = append(f.params, p)
f.mu.Unlock()
return &fakeRunner{sink: p.Sink, approver: p.Approver, behavior: f.behavior}, func() {}, nil
}
// --- a minimal JSON-RPC client over the wire, for integration tests ---
type frame struct {
ID *json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
Result json.RawMessage `json:"result"`
Error *rpcError `json:"error"`
}
type rpcClient struct {
enc *json.Encoder
wmu sync.Mutex
mu sync.Mutex
nextID int64
waits map[int64]chan frame
notifs chan frame
reqs chan frame
}
func newRPCClient(in io.Writer, out io.Reader) *rpcClient {
c := &rpcClient{
enc: json.NewEncoder(in),
waits: make(map[int64]chan frame),
notifs: make(chan frame, 64),
reqs: make(chan frame, 16),
}
dec := json.NewDecoder(out)
go func() {
for {
var f frame
if err := dec.Decode(&f); err != nil {
return
}
switch {
case f.Method != "" && f.ID != nil:
c.reqs <- f
case f.Method != "" && f.ID == nil:
c.notifs <- f
case f.Method == "" && f.ID != nil:
var id int64
if json.Unmarshal(*f.ID, &id) != nil {
continue
}
c.mu.Lock()
ch := c.waits[id]
delete(c.waits, id)
c.mu.Unlock()
if ch != nil {
ch <- f
}
}
}
}()
return c
}
func (c *rpcClient) send(v any) {
c.wmu.Lock()
_ = c.enc.Encode(v)
c.wmu.Unlock()
}
func (c *rpcClient) callAsync(method string, params any) chan frame {
c.mu.Lock()
c.nextID++
id := c.nextID
ch := make(chan frame, 1)
c.waits[id] = ch
c.mu.Unlock()
c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
return ch
}
func (c *rpcClient) call(t *testing.T, method string, params any) frame {
t.Helper()
select {
case f := <-c.callAsync(method, params):
return f
case <-time.After(2 * time.Second):
t.Fatalf("%s: timed out", method)
return frame{}
}
}
func (c *rpcClient) notify(method string, params any) {
c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
}
func (c *rpcClient) reply(id *json.RawMessage, result any) {
c.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
}
func startServer(t *testing.T, factory Factory) (*rpcClient, func()) {
t.Helper()
inR, inW := io.Pipe()
outR, outW := io.Pipe()
done := make(chan struct{})
go func() {
_ = Serve(context.Background(), inR, outW, factory, AgentInfo{Name: "reasonix-test", Version: "0"})
close(done)
}()
client := newRPCClient(inW, outR)
return client, func() {
_ = inW.Close()
<-done
_ = outW.Close()
}
}
// drainPrompt collects session/update notifications until the prompt's response
// arrives, then sweeps any notifications still buffered.
func drainPrompt(t *testing.T, c *rpcClient, promptCh chan frame) ([]frame, frame) {
t.Helper()
var notifs []frame
var resp frame
for {
select {
case f := <-c.notifs:
notifs = append(notifs, f)
case resp = <-promptCh:
for {
select {
case f := <-c.notifs:
notifs = append(notifs, f)
default:
return notifs, resp
}
}
case <-time.After(2 * time.Second):
t.Fatal("session/prompt: timed out")
}
}
}
func updateKind(t *testing.T, f frame) string {
t.Helper()
var p struct {
Update struct {
SessionUpdate string `json:"sessionUpdate"`
} `json:"update"`
}
if err := json.Unmarshal(f.Params, &p); err != nil {
t.Fatalf("decode update: %v", err)
}
return p.Update.SessionUpdate
}
// --- tests ---
func TestServeLifecycle(t *testing.T) {
factory := &fakeFactory{behavior: func(_ context.Context, r *fakeRunner, input string) error {
r.sink.Emit(event.Event{Kind: event.Text, Text: "hi " + input})
r.sink.Emit(event.Event{Kind: event.ToolDispatch, Tool: event.Tool{ID: "c1", Name: "ls", Args: `{}`}})
r.sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "c1", Name: "ls", Output: "file.go"}})
return nil
}}
client, stop := startServer(t, factory)
defer stop()
initResp := client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
var ir InitializeResult
if err := json.Unmarshal(initResp.Result, &ir); err != nil {
t.Fatalf("initialize result: %v", err)
}
if ir.ProtocolVersion != ProtocolVersion {
t.Errorf("protocolVersion = %d, want %d", ir.ProtocolVersion, ProtocolVersion)
}
if !ir.AgentCapabilities.PromptCapabilities.EmbeddedContext {
t.Errorf("embeddedContext should be advertised")
}
if ir.AgentCapabilities.PromptCapabilities.Image {
t.Errorf("image must not be advertised")
}
newResp := client.call(t, "session/new", SessionNewParams{Cwd: "/tmp"})
var nr SessionNewResult
if err := json.Unmarshal(newResp.Result, &nr); err != nil || nr.SessionID == "" {
t.Fatalf("session/new result: %v (%q)", err, nr.SessionID)
}
promptCh := client.callAsync("session/prompt", SessionPromptParams{
SessionID: nr.SessionID,
Prompt: []ContentBlock{{Type: "text", Text: "there"}},
})
notifs, resp := drainPrompt(t, client, promptCh)
kinds := map[string]bool{}
for _, n := range notifs {
kinds[updateKind(t, n)] = true
}
for _, want := range []string{"agent_message_chunk", "tool_call", "tool_call_update"} {
if !kinds[want] {
t.Errorf("missing %s update; saw %v", want, kinds)
}
}
var pr SessionPromptResult
if err := json.Unmarshal(resp.Result, &pr); err != nil {
t.Fatalf("prompt result: %v", err)
}
if pr.StopReason != StopEndTurn {
t.Errorf("stopReason = %q, want %q", pr.StopReason, StopEndTurn)
}
}
func TestServePermissionRoundTrip(t *testing.T) {
factory := &fakeFactory{behavior: func(ctx context.Context, r *fakeRunner, _ string) error {
allow, remember, err := r.approver.Approve(ctx, "bash", "rm -rf /", json.RawMessage(`{"command":"rm -rf /"}`))
if err != nil {
return err
}
r.sink.Emit(event.Event{Kind: event.Text, Text: fmt.Sprintf("allow=%v remember=%v", allow, remember)})
return nil
}}
client, stop := startServer(t, factory)
defer stop()
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
newResp := client.call(t, "session/new", SessionNewParams{})
var nr SessionNewResult
json.Unmarshal(newResp.Result, &nr)
promptCh := client.callAsync("session/prompt", SessionPromptParams{
SessionID: nr.SessionID,
Prompt: []ContentBlock{{Type: "text", Text: "go"}},
})
// The agent's Approve fires a session/request_permission request; answer it.
select {
case req := <-client.reqs:
if req.Method != "session/request_permission" {
t.Fatalf("unexpected request %q", req.Method)
}
var pr PermissionRequestParams
if err := json.Unmarshal(req.Params, &pr); err != nil {
t.Fatalf("permission params: %v", err)
}
if pr.ToolCall.Kind != "execute" {
t.Errorf("permission kind = %q, want execute", pr.ToolCall.Kind)
}
client.reply(req.ID, PermissionRequestResult{
Outcome: PermissionOutcome{Outcome: "selected", OptionID: string(OptAllowAlways)},
})
case <-time.After(2 * time.Second):
t.Fatal("no permission request received")
}
notifs, resp := drainPrompt(t, client, promptCh)
var pr SessionPromptResult
json.Unmarshal(resp.Result, &pr)
if pr.StopReason != StopEndTurn {
t.Errorf("stopReason = %q, want end_turn", pr.StopReason)
}
var sawDecision bool
for _, n := range notifs {
var p struct {
Update struct {
Content struct {
Text string `json:"text"`
} `json:"content"`
} `json:"update"`
}
json.Unmarshal(n.Params, &p)
if p.Update.Content.Text == "allow=true remember=true" {
sawDecision = true
}
}
if !sawDecision {
t.Errorf("approver did not resolve to allow_always (allow=true remember=true)")
}
}
func TestServeCancel(t *testing.T) {
started := make(chan struct{})
factory := &fakeFactory{behavior: func(ctx context.Context, _ *fakeRunner, _ string) error {
close(started)
<-ctx.Done()
return ctx.Err()
}}
client, stop := startServer(t, factory)
defer stop()
client.call(t, "initialize", InitializeParams{ProtocolVersion: 1})
newResp := client.call(t, "session/new", SessionNewParams{})
var nr SessionNewResult
json.Unmarshal(newResp.Result, &nr)
promptCh := client.callAsync("session/prompt", SessionPromptParams{
SessionID: nr.SessionID,
Prompt: []ContentBlock{{Type: "text", Text: "loop"}},
})
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("prompt never started")
}
client.notify("session/cancel", SessionCancelParams{SessionID: nr.SessionID})
select {
case resp := <-promptCh:
var pr SessionPromptResult
json.Unmarshal(resp.Result, &pr)
if pr.StopReason != StopCancelled {
t.Errorf("stopReason = %q, want cancelled", pr.StopReason)
}
case <-time.After(2 * time.Second):
t.Fatal("cancel did not end the prompt")
}
}
func TestServeUnknownMethod(t *testing.T) {
factory := &fakeFactory{behavior: func(context.Context, *fakeRunner, string) error { return nil }}
client, stop := startServer(t, factory)
defer stop()
resp := client.call(t, "does/not/exist", nil)
if resp.Error == nil {
t.Fatal("expected an error response")
}
if resp.Error.Code != ErrMethodNotFound {
t.Errorf("error code = %d, want %d", resp.Error.Code, ErrMethodNotFound)
}
}
+258
View File
@@ -0,0 +1,258 @@
package acp
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"sync"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/permission"
"reasonix/internal/plugin"
)
// SessionParams is everything a Factory needs to assemble one ACP session's
// agent. Sink and Approver are owned by this package (an updateSink and an
// rpcApprover bound to the session id); the Factory wires them into the agent's
// event sink and into permission.NewGate(policy, approver) respectively. Model is
// empty when the client did not request one — the Factory picks its default.
//
// Cwd roots the session's file tools and bash. MCPServers are the stdio MCP
// servers the client asked the agent to connect for this session.
type SessionParams struct {
Model string
Cwd string
MCPServers []plugin.Spec
Sink event.Sink
Approver permission.Approver
}
// Factory builds the per-session agent runner. The composition root (the cli's
// `reasonix acp` command, wired once the v2 refactor lands) implements it by
// reusing the extracted session-assembly logic: a Provider, a tool Registry
// rooted at Cwd, a per-session MCP host from MCPServers, an interactive Gate
// backed by the Approver, and the event Sink. The returned cleanup releases
// per-session resources (MCP subprocesses) and is called on teardown.
type Factory interface {
NewSession(ctx context.Context, p SessionParams) (runner agent.Runner, cleanup func(), err error)
}
// AgentInfo identifies this agent to clients in the initialize reply.
type AgentInfo struct {
Name string
Version string
}
// Serve runs an ACP agent on r/w (stdin/stdout in production) until the input
// ends or ctx is cancelled. It owns the JSON-RPC connection and the session
// registry; the Factory supplies the kernel wiring. This is the single entry
// point the `reasonix acp` command will call.
//
// stdout is the JSON-RPC channel: callers must keep all other output (logs,
// diagnostics) off w and on stderr, or the wire corrupts.
func Serve(ctx context.Context, r io.Reader, w io.Writer, factory Factory, info AgentInfo) error {
conn := NewConn(r, w)
svc := &service{
conn: conn,
factory: factory,
info: info,
sessions: make(map[string]*acpSession),
}
conn.Handle("initialize", svc.initialize)
conn.Handle("session/new", svc.sessionNew)
conn.Handle("session/prompt", svc.sessionPrompt)
conn.HandleNotify("session/cancel", svc.sessionCancel)
defer svc.closeAll()
return conn.Serve(ctx)
}
// closeAll tears down every open session's per-session resources (MCP
// subprocesses) when the connection ends.
func (s *service) closeAll() {
s.mu.Lock()
sessions := s.sessions
s.sessions = make(map[string]*acpSession)
s.mu.Unlock()
for _, sess := range sessions {
sess.abort()
if sess.cleanup != nil {
sess.cleanup()
}
}
}
// service holds the connection-wide ACP state: the factory, agent identity, and
// the live session registry.
type service struct {
conn *Conn
factory Factory
info AgentInfo
mu sync.Mutex
sessions map[string]*acpSession
}
// acpSession is one open session: its runner plus the cancel func of the
// in-flight turn (nil when idle) so session/cancel can abort it.
type acpSession struct {
id string
runner agent.Runner
cleanup func()
mu sync.Mutex
cancel context.CancelFunc
}
func (s *acpSession) setCancel(c context.CancelFunc) {
s.mu.Lock()
s.cancel = c
s.mu.Unlock()
}
func (s *acpSession) abort() {
s.mu.Lock()
c := s.cancel
s.mu.Unlock()
if c != nil {
c()
}
}
// initialize advertises the agent's fixed capability set. The flags match main
// exactly: no loadSession, embedded resource text but no image/audio prompts,
// and stdio-only MCP (no http/sse).
func (s *service) initialize(_ context.Context, _ json.RawMessage) (any, error) {
return InitializeResult{
ProtocolVersion: ProtocolVersion,
AgentCapabilities: AgentCapabilities{
LoadSession: false,
PromptCapabilities: PromptCapabilities{
Image: false,
Audio: false,
EmbeddedContext: true,
},
MCPCapabilities: MCPCapabilities{HTTP: false, SSE: false},
},
AgentInfo: Implementation{Name: s.info.Name, Version: s.info.Version},
AuthMethods: []any{},
}, nil
}
// sessionNew opens a session: it mints an id, builds the session's sink and
// approver bound to that id, asks the Factory to assemble the agent, and
// registers it.
func (s *service) sessionNew(ctx context.Context, raw json.RawMessage) (any, error) {
var p SessionNewParams
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/new: " + err.Error()}
}
}
id, err := newSessionID()
if err != nil {
return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
}
params := SessionParams{
Cwd: p.Cwd,
MCPServers: mcpSpecs(p.MCPServers),
Sink: newUpdateSink(s.conn, id),
Approver: newRPCApprover(s.conn, id),
}
runner, cleanup, err := s.factory.NewSession(ctx, params)
if err != nil {
return nil, &RPCError{Code: ErrInternal, Message: "session/new: " + err.Error()}
}
s.mu.Lock()
s.sessions[id] = &acpSession{id: id, runner: runner, cleanup: cleanup}
s.mu.Unlock()
return SessionNewResult{SessionID: id}, nil
}
// sessionPrompt runs one turn. It flattens the prompt blocks to text, runs the
// session's agent under a per-turn cancelable context (so session/cancel can stop
// it), and reports why the turn ended.
func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any, error) {
var p SessionPromptParams
if err := json.Unmarshal(raw, &p); err != nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: " + err.Error()}
}
sess := s.session(p.SessionID)
if sess == nil {
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: unknown session " + p.SessionID}
}
text := FlattenPrompt(p.Prompt)
if text == "" {
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: empty prompt"}
}
runCtx, cancel := context.WithCancel(ctx)
sess.setCancel(cancel)
runErr := sess.runner.Run(runCtx, text)
sess.setCancel(nil)
cancel()
stop := StopEndTurn
if runErr != nil {
if runCtx.Err() != nil {
stop = StopCancelled
} else {
stop = StopError
}
}
return SessionPromptResult{StopReason: stop}, nil
}
// sessionCancel aborts a session's in-flight turn, if any. It is a notification:
// no reply, and an unknown session is silently ignored.
func (s *service) sessionCancel(_ context.Context, raw json.RawMessage) {
var p SessionCancelParams
if err := json.Unmarshal(raw, &p); err != nil {
return
}
if sess := s.session(p.SessionID); sess != nil {
sess.abort()
}
}
func (s *service) session(id string) *acpSession {
s.mu.Lock()
defer s.mu.Unlock()
return s.sessions[id]
}
// mcpSpecs converts ACP stdio MCP server declarations to plugin.Spec. ACP's
// session/new only carries stdio servers (the agent advertises http/sse off).
func mcpSpecs(in []MCPServerSpec) []plugin.Spec {
if len(in) == 0 {
return nil
}
out := make([]plugin.Spec, 0, len(in))
for _, m := range in {
out = append(out, plugin.Spec{
Name: m.Name,
Type: "stdio",
Command: m.Command,
Args: m.Args,
Env: m.Env,
})
}
return out
}
// newSessionID returns a random RFC 4122 v4 UUID string used to address a session.
func newSessionID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}