mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
4dad578f2e
commit
5d40158aa6
8 files changed
+1716
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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:])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user