Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,71 @@ Helper constructors are provided to reduce boilerplate when working with union t
- Tool content: `acp.ToolContent`, `acp.ToolDiffContent`, `acp.ToolTerminalRef`.
- Utility: `acp.Ptr[T]` for pointer fields in request/update structs.

### Extension methods

ACP supports **extension methods** for custom JSON-RPC methods whose names start with `_`.
Use them to add functionality without conflicting with future ACP versions.

#### Handling inbound extension methods

Implement `acp.ExtensionMethodHandler` on your Agent or Client. Your handler will be
invoked for any incoming method starting with `_`.

```go
// HandleExtensionMethod handles ACP extension methods (names starting with "_").
func (a MyAgent) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) {
switch method {
case "_example.com/hello":
var p struct {
Name string `json:"name"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, err
}
return map[string]any{"greeting": "hello " + p.Name}, nil
default:
return nil, acp.NewMethodNotFound(method)
}
}
```

> Note: Per the ACP spec, unknown extension notifications should be ignored.
> This SDK suppresses noisy logs for unhandled extension notifications that return
> “Method not found”.

#### Calling extension methods

From either side, use `CallExtension` / `NotifyExtension` on the connection.

```go
raw, err := conn.CallExtension(ctx, "_example.com/hello", map[string]any{"name": "world"})
if err != nil {
return err
}

var resp struct {
Greeting string `json:"greeting"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return err
}

if err := conn.NotifyExtension(ctx, "_example.com/progress", map[string]any{"pct": 50}); err != nil {
return err
}
```

#### Advertising extension support via `_meta`

ACP uses the `_meta` field inside capability objects as the negotiation/advertising
surface for extensions.

- Client -> Agent: `InitializeRequest.ClientCapabilities.Meta`
- Agent -> Client: `InitializeResponse.AgentCapabilities.Meta`

Keys `traceparent`, `tracestate`, and `baggage` are reserved in `_meta` for W3C trace
context/OpenTelemetry compatibility.

### Study a Production Implementation

For a complete, production‑ready integration, see the
Expand Down
200 changes: 197 additions & 3 deletions acp_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package acp

import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
Expand All @@ -21,8 +26,12 @@ type clientFuncs struct {
ReleaseTerminalFunc func(context.Context, ReleaseTerminalRequest) (ReleaseTerminalResponse, error)
TerminalOutputFunc func(context.Context, TerminalOutputRequest) (TerminalOutputResponse, error)
WaitForTerminalExitFunc func(context.Context, WaitForTerminalExitRequest) (WaitForTerminalExitResponse, error)

HandleExtensionMethodFunc func(context.Context, string, json.RawMessage) (any, error)
}

var _ ExtensionMethodHandler = (*clientFuncs)(nil)

var _ Client = (*clientFuncs)(nil)

func (c clientFuncs) WriteTextFile(ctx context.Context, p WriteTextFileRequest) (WriteTextFileResponse, error) {
Expand Down Expand Up @@ -93,6 +102,13 @@ func (c *clientFuncs) WaitForTerminalExit(ctx context.Context, params WaitForTer
return WaitForTerminalExitResponse{}, nil
}

func (c clientFuncs) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) {
if c.HandleExtensionMethodFunc != nil {
return c.HandleExtensionMethodFunc(ctx, method, params)
}
return nil, NewMethodNotFound(method)
}

type agentFuncs struct {
InitializeFunc func(context.Context, InitializeRequest) (InitializeResponse, error)
NewSessionFunc func(context.Context, NewSessionRequest) (NewSessionResponse, error)
Expand All @@ -102,12 +118,15 @@ type agentFuncs struct {
CancelFunc func(context.Context, CancelNotification) error
SetSessionModeFunc func(ctx context.Context, params SetSessionModeRequest) (SetSessionModeResponse, error)
SetSessionModelFunc func(ctx context.Context, params SetSessionModelRequest) (SetSessionModelResponse, error)

HandleExtensionMethodFunc func(context.Context, string, json.RawMessage) (any, error)
}

var (
_ Agent = (*agentFuncs)(nil)
_ AgentLoader = (*agentFuncs)(nil)
_ AgentExperimental = (*agentFuncs)(nil)
_ Agent = (*agentFuncs)(nil)
_ AgentLoader = (*agentFuncs)(nil)
_ AgentExperimental = (*agentFuncs)(nil)
_ ExtensionMethodHandler = (*agentFuncs)(nil)
)

func (a agentFuncs) Initialize(ctx context.Context, p InitializeRequest) (InitializeResponse, error) {
Expand Down Expand Up @@ -168,6 +187,13 @@ func (a agentFuncs) SetSessionModel(ctx context.Context, params SetSessionModelR
return SetSessionModelResponse{}, nil
}

func (a agentFuncs) HandleExtensionMethod(ctx context.Context, method string, params json.RawMessage) (any, error) {
if a.HandleExtensionMethodFunc != nil {
return a.HandleExtensionMethodFunc(ctx, method, params)
}
return nil, NewMethodNotFound(method)
}

// Test bidirectional error handling similar to typescript/acp.test.ts
func TestConnectionHandlesErrorsBidirectional(t *testing.T) {
ctx := context.Background()
Expand Down Expand Up @@ -819,3 +845,171 @@ func TestRequestHandlerCanMakeNestedRequest(t *testing.T) {
t.Fatalf("prompt failed: %v", err)
}
}

type extEchoParams struct {
Msg string `json:"msg"`
}

type extEchoResult struct {
Msg string `json:"msg"`
}

type agentNoExtensions struct{}

func (agentNoExtensions) Authenticate(ctx context.Context, params AuthenticateRequest) (AuthenticateResponse, error) {
return AuthenticateResponse{}, nil
}

func (agentNoExtensions) Initialize(ctx context.Context, params InitializeRequest) (InitializeResponse, error) {
return InitializeResponse{}, nil
}

func (agentNoExtensions) Cancel(ctx context.Context, params CancelNotification) error { return nil }

func (agentNoExtensions) NewSession(ctx context.Context, params NewSessionRequest) (NewSessionResponse, error) {
return NewSessionResponse{}, nil
}

func (agentNoExtensions) Prompt(ctx context.Context, params PromptRequest) (PromptResponse, error) {
return PromptResponse{}, nil
}

func (agentNoExtensions) SetSessionMode(ctx context.Context, params SetSessionModeRequest) (SetSessionModeResponse, error) {
return SetSessionModeResponse{}, nil
}

func TestExtensionMethods_ClientToAgentRequest(t *testing.T) {
c2aR, c2aW := io.Pipe()
a2cR, a2cW := io.Pipe()

method := "_vendor.test/echo"

ag := NewAgentSideConnection(agentFuncs{
HandleExtensionMethodFunc: func(ctx context.Context, gotMethod string, params json.RawMessage) (any, error) {
if gotMethod != method {
return nil, NewInternalError(map[string]any{"expected": method, "got": gotMethod})
}
var p extEchoParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, err
}
return extEchoResult{Msg: p.Msg}, nil
},
}, a2cW, c2aR)

_ = ag

c := NewClientSideConnection(&clientFuncs{}, c2aW, a2cR)

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

raw, err := c.CallExtension(ctx, method, extEchoParams{Msg: "hi"})
if err != nil {
t.Fatalf("CallExtension: %v", err)
}
var resp extEchoResult
if err := json.Unmarshal(raw, &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.Msg != "hi" {
t.Fatalf("unexpected response: %#v", resp)
}
}

func TestExtensionMethods_UnknownRequest_ReturnsMethodNotFound(t *testing.T) {
c2aR, c2aW := io.Pipe()
a2cR, a2cW := io.Pipe()

NewAgentSideConnection(agentNoExtensions{}, a2cW, c2aR)
c := NewClientSideConnection(&clientFuncs{}, c2aW, a2cR)

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

_, err := c.CallExtension(ctx, "_vendor.test/missing", extEchoParams{Msg: "hi"})
if err == nil {
t.Fatalf("expected error")
}
var re *RequestError
if !errors.As(err, &re) {
t.Fatalf("expected *RequestError, got %T: %v", err, err)
}
if re.Code != -32601 {
t.Fatalf("expected -32601 method not found, got %d", re.Code)
}
}

func TestExtensionMethods_UnknownNotification_DoesNotLog(t *testing.T) {
c2aR, c2aW := io.Pipe()
a2cR, a2cW := io.Pipe()

done := make(chan struct{})

ag := NewAgentSideConnection(agentFuncs{
HandleExtensionMethodFunc: func(ctx context.Context, method string, params json.RawMessage) (any, error) {
close(done)
return nil, NewMethodNotFound(method)
},
}, a2cW, c2aR)

var logBuf bytes.Buffer
ag.SetLogger(slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug})))

c := NewClientSideConnection(&clientFuncs{}, c2aW, a2cR)

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

if err := c.NotifyExtension(ctx, "_vendor.test/notify", map[string]any{"hello": "world"}); err != nil {
t.Fatalf("NotifyExtension: %v", err)
}

select {
case <-done:
// ok
case <-ctx.Done():
t.Fatalf("timeout waiting for notification handler")
}

if strings.Contains(logBuf.String(), "failed to handle notification") {
t.Fatalf("unexpected notification error log: %s", logBuf.String())
}
}

func TestExtensionMethods_AgentToClientRequest(t *testing.T) {
c2aR, c2aW := io.Pipe()
a2cR, a2cW := io.Pipe()

method := "_vendor.test/echo"

_ = NewClientSideConnection(&clientFuncs{
HandleExtensionMethodFunc: func(ctx context.Context, gotMethod string, params json.RawMessage) (any, error) {
if gotMethod != method {
return nil, NewInternalError(map[string]any{"expected": method, "got": gotMethod})
}
var p extEchoParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, err
}
return extEchoResult{Msg: p.Msg}, nil
},
}, c2aW, a2cR)

ag := NewAgentSideConnection(agentFuncs{}, a2cW, c2aR)

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()

raw, err := ag.CallExtension(ctx, method, extEchoParams{Msg: "hi"})
if err != nil {
t.Fatalf("CallExtension: %v", err)
}
var resp extEchoResult
if err := json.Unmarshal(raw, &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp.Msg != "hi" {
t.Fatalf("unexpected response: %#v", resp)
}
}
2 changes: 1 addition & 1 deletion agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func NewAgentSideConnection(agent Agent, peerInput io.Writer, peerOutput io.Read
asc := &AgentSideConnection{}
asc.agent = agent
asc.sessionCancels = make(map[string]context.CancelFunc)
asc.conn = NewConnection(asc.handle, peerInput, peerOutput)
asc.conn = NewConnection(asc.handleWithExtensions, peerInput, peerOutput)
return asc
}

Expand Down
2 changes: 1 addition & 1 deletion client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type ClientSideConnection struct {
func NewClientSideConnection(client Client, peerInput io.Writer, peerOutput io.Reader) *ClientSideConnection {
csc := &ClientSideConnection{}
csc.client = client
csc.conn = NewConnection(csc.handle, peerInput, peerOutput)
csc.conn = NewConnection(csc.handleWithExtensions, peerInput, peerOutput)
return csc
}

Expand Down
5 changes: 5 additions & 0 deletions connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"errors"
"io"
"log/slog"
"strings"
"sync"
"sync/atomic"
)
Expand Down Expand Up @@ -152,6 +153,10 @@ func (c *Connection) handleInbound(req *anyMessage) {
if req.ID == nil {
// Notification: no response is sent; log handler errors to surface decode failures.
if err != nil {
// Per ACP, unknown extension notifications should be ignored.
if err.Code == -32601 && strings.HasPrefix(req.Method, "_") {
return
}
c.loggerOrDefault().Error("failed to handle notification", "method", req.Method, "err", err)
}
return
Expand Down
Loading
Loading