Skip to content

Commit 8c8da69

Browse files
committed
feat: add experimental ACP mode (--experimental-acp)
Add support for Agent Control Protocol (ACP) as an alternative to terminal emulation. ACP uses JSON-RPC over stdin/stdout pipes. - Introduce AgentIO interface to abstract PTY vs ACP transports - Add ACPConversation implementing Conversation interface - Add --experimental-acp flag (mutually exclusive with --print-openapi) - Add e2e test with mock ACP agent - Block `attach` when using --experimental-acp (no terminal) - Update chat UI to show ACP tool calls Other changes: - chat: Fix redundant draft filtering from finally block Created using Mux (Opus 4.5)
1 parent ca5d5d4 commit 8c8da69

File tree

16 files changed

+1255
-59
lines changed

16 files changed

+1255
-59
lines changed

chat/src/components/chat-provider.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,6 @@ export function ChatProvider({ children }: PropsWithChildren) {
304304
});
305305
} finally {
306306
if (type === "user") {
307-
setMessages((prevMessages) =>
308-
prevMessages.filter((m) => !isDraftMessage(m))
309-
);
310307
setLoading(false);
311308
}
312309
}

cmd/attach/attach.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,46 @@ func WriteRawInputOverHTTP(ctx context.Context, url string, msg string) error {
129129
return nil
130130
}
131131

132+
133+
// statusResponse is used to parse the /status endpoint response.
134+
// The ACPMode field may not be present on older servers.
135+
type statusResponse struct {
136+
Status string `json:"status"`
137+
AgentType string `json:"agent_type"`
138+
ACPMode bool `json:"acp_mode"`
139+
}
140+
141+
func checkACPMode(remoteUrl string) error {
142+
resp, err := http.Get(remoteUrl + "/status")
143+
if err != nil {
144+
return xerrors.Errorf("failed to check server status: %w", err)
145+
}
146+
defer resp.Body.Close()
147+
148+
if resp.StatusCode != http.StatusOK {
149+
// Server doesn't support /status or had an error, continue anyway
150+
return nil
151+
}
152+
153+
var status statusResponse
154+
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
155+
// Can't parse response, continue anyway
156+
return nil
157+
}
158+
159+
if status.ACPMode {
160+
return xerrors.New("attach is not supported in ACP mode. The server is running with --experimental-acp which uses JSON-RPC instead of terminal emulation.")
161+
}
162+
163+
return nil
164+
}
165+
132166
func runAttach(remoteUrl string) error {
167+
// Check if server is running in ACP mode (attach not supported)
168+
if err := checkACPMode(remoteUrl); err != nil {
169+
return err
170+
}
171+
133172
ctx, cancel := context.WithCancel(context.Background())
134173
defer cancel()
135174
stdin := int(os.Stdin.Fd())

cmd/server/server.go

Lines changed: 68 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"github.com/coder/agentapi/lib/httpapi"
2020
"github.com/coder/agentapi/lib/logctx"
2121
"github.com/coder/agentapi/lib/msgfmt"
22+
st "github.com/coder/agentapi/lib/screentracker"
2223
"github.com/coder/agentapi/lib/termexec"
2324
)
2425

@@ -104,11 +105,33 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
104105
}
105106

106107
printOpenAPI := viper.GetBool(FlagPrintOpenAPI)
108+
experimentalACP := viper.GetBool(FlagExperimentalACP)
109+
110+
if printOpenAPI && experimentalACP {
111+
return xerrors.Errorf("flags --%s and --%s are mutually exclusive", FlagPrintOpenAPI, FlagExperimentalACP)
112+
}
113+
114+
var agentIO st.AgentIO
115+
var transport = "pty"
107116
var process *termexec.Process
117+
var acpWait func() error
118+
108119
if printOpenAPI {
109-
process = nil
120+
agentIO = nil
121+
} else if experimentalACP {
122+
acpResult, err := httpapi.SetupACP(ctx, httpapi.SetupACPConfig{
123+
Program: agent,
124+
ProgramArgs: argsToPass[1:],
125+
})
126+
if err != nil {
127+
return xerrors.Errorf("failed to setup ACP: %w", err)
128+
}
129+
acpIO := acpResult.AgentIO
130+
acpWait = acpResult.Wait
131+
agentIO = acpIO
132+
transport = "acp"
110133
} else {
111-
process, err = httpapi.SetupProcess(ctx, httpapi.SetupProcessConfig{
134+
proc, err := httpapi.SetupProcess(ctx, httpapi.SetupProcessConfig{
112135
Program: agent,
113136
ProgramArgs: argsToPass[1:],
114137
TerminalWidth: termWidth,
@@ -118,11 +141,14 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
118141
if err != nil {
119142
return xerrors.Errorf("failed to setup process: %w", err)
120143
}
144+
process = proc
145+
agentIO = proc
121146
}
122147
port := viper.GetInt(FlagPort)
123148
srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{
124149
AgentType: agentType,
125-
Process: process,
150+
AgentIO: agentIO,
151+
Transport: transport,
126152
Port: port,
127153
ChatBasePath: viper.GetString(FlagChatBasePath),
128154
AllowedHosts: viper.GetStringSlice(FlagAllowedHosts),
@@ -138,19 +164,34 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
138164
}
139165
logger.Info("Starting server on port", "port", port)
140166
processExitCh := make(chan error, 1)
141-
go func() {
142-
defer close(processExitCh)
143-
if err := process.Wait(); err != nil {
144-
if errors.Is(err, termexec.ErrNonZeroExitCode) {
145-
processExitCh <- xerrors.Errorf("========\n%s\n========\n: %w", strings.TrimSpace(process.ReadScreen()), err)
146-
} else {
147-
processExitCh <- xerrors.Errorf("failed to wait for process: %w", err)
167+
// Wait for process exit in PTY mode
168+
if process != nil {
169+
go func() {
170+
defer close(processExitCh)
171+
if err := process.Wait(); err != nil {
172+
if errors.Is(err, termexec.ErrNonZeroExitCode) {
173+
processExitCh <- xerrors.Errorf("========\n%s\n========\n: %w", strings.TrimSpace(process.ReadScreen()), err)
174+
} else {
175+
processExitCh <- xerrors.Errorf("failed to wait for process: %w", err)
176+
}
148177
}
149-
}
150-
if err := srv.Stop(ctx); err != nil {
151-
logger.Error("Failed to stop server", "error", err)
152-
}
153-
}()
178+
if err := srv.Stop(ctx); err != nil {
179+
logger.Error("Failed to stop server", "error", err)
180+
}
181+
}()
182+
}
183+
// Wait for process exit in ACP mode
184+
if acpWait != nil {
185+
go func() {
186+
defer close(processExitCh)
187+
if err := acpWait(); err != nil {
188+
processExitCh <- xerrors.Errorf("ACP process exited: %w", err)
189+
}
190+
if err := srv.Stop(ctx); err != nil {
191+
logger.Error("Failed to stop server", "error", err)
192+
}
193+
}()
194+
}
154195
if err := srv.Start(); err != nil && err != context.Canceled && err != http.ErrServerClosed {
155196
return xerrors.Errorf("failed to start server: %w", err)
156197
}
@@ -180,16 +221,17 @@ type flagSpec struct {
180221
}
181222

182223
const (
183-
FlagType = "type"
184-
FlagPort = "port"
185-
FlagPrintOpenAPI = "print-openapi"
186-
FlagChatBasePath = "chat-base-path"
187-
FlagTermWidth = "term-width"
188-
FlagTermHeight = "term-height"
189-
FlagAllowedHosts = "allowed-hosts"
190-
FlagAllowedOrigins = "allowed-origins"
191-
FlagExit = "exit"
192-
FlagInitialPrompt = "initial-prompt"
224+
FlagType = "type"
225+
FlagPort = "port"
226+
FlagPrintOpenAPI = "print-openapi"
227+
FlagChatBasePath = "chat-base-path"
228+
FlagTermWidth = "term-width"
229+
FlagTermHeight = "term-height"
230+
FlagAllowedHosts = "allowed-hosts"
231+
FlagAllowedOrigins = "allowed-origins"
232+
FlagExit = "exit"
233+
FlagInitialPrompt = "initial-prompt"
234+
FlagExperimentalACP = "experimental-acp"
193235
)
194236

195237
func CreateServerCmd() *cobra.Command {
@@ -228,6 +270,7 @@ func CreateServerCmd() *cobra.Command {
228270
// localhost:3284 is the default origin when you open the chat interface in your browser. localhost:3000 and 3001 are used during development.
229271
{FlagAllowedOrigins, "o", []string{"http://localhost:3284", "http://localhost:3000", "http://localhost:3001"}, "HTTP allowed origins. Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_ORIGINS env var", "stringSlice"},
230272
{FlagInitialPrompt, "I", "", "Initial prompt for the agent. Recommended only if the agent doesn't support initial prompt in interaction mode. Will be read from stdin if piped (e.g., echo 'prompt' | agentapi server -- my-agent)", "string"},
273+
{FlagExperimentalACP, "", false, "Use experimental ACP transport instead of PTY", "bool"},
231274
}
232275

233276
for _, spec := range flagSpecs {

e2e/acp_echo.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
//go:build ignore
2+
3+
package main
4+
5+
import (
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"os"
10+
"os/signal"
11+
"strings"
12+
13+
acp "github.com/coder/acp-go-sdk"
14+
)
15+
16+
// ScriptEntry defines a single entry in the test script.
17+
type ScriptEntry struct {
18+
ExpectMessage string `json:"expectMessage"`
19+
ThinkDurationMS int64 `json:"thinkDurationMS"`
20+
ResponseMessage string `json:"responseMessage"`
21+
}
22+
23+
// acpEchoAgent implements the ACP Agent interface for testing.
24+
type acpEchoAgent struct {
25+
script []ScriptEntry
26+
scriptIndex int
27+
conn *acp.AgentSideConnection
28+
sessionID acp.SessionId
29+
}
30+
31+
var _ acp.Agent = (*acpEchoAgent)(nil)
32+
33+
func main() {
34+
if len(os.Args) != 2 {
35+
fmt.Fprintln(os.Stderr, "Usage: acp_echo <script.json>")
36+
os.Exit(1)
37+
}
38+
39+
script, err := loadScript(os.Args[1])
40+
if err != nil {
41+
fmt.Fprintf(os.Stderr, "Error loading script: %v\n", err)
42+
os.Exit(1)
43+
}
44+
45+
if len(script) == 0 {
46+
fmt.Fprintln(os.Stderr, "Script is empty")
47+
os.Exit(1)
48+
}
49+
50+
sigCh := make(chan os.Signal, 1)
51+
signal.Notify(sigCh, os.Interrupt)
52+
go func() {
53+
<-sigCh
54+
os.Exit(0)
55+
}()
56+
57+
agent := &acpEchoAgent{
58+
script: script,
59+
}
60+
61+
conn := acp.NewAgentSideConnection(agent, os.Stdout, os.Stdin)
62+
agent.conn = conn
63+
64+
<-conn.Done()
65+
}
66+
67+
func (a *acpEchoAgent) Initialize(_ context.Context, _ acp.InitializeRequest) (acp.InitializeResponse, error) {
68+
return acp.InitializeResponse{
69+
ProtocolVersion: acp.ProtocolVersionNumber,
70+
AgentCapabilities: acp.AgentCapabilities{},
71+
}, nil
72+
}
73+
74+
func (a *acpEchoAgent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
75+
return acp.AuthenticateResponse{}, nil
76+
}
77+
78+
func (a *acpEchoAgent) Cancel(_ context.Context, _ acp.CancelNotification) error {
79+
return nil
80+
}
81+
82+
func (a *acpEchoAgent) NewSession(_ context.Context, _ acp.NewSessionRequest) (acp.NewSessionResponse, error) {
83+
a.sessionID = "test-session"
84+
return acp.NewSessionResponse{
85+
SessionId: a.sessionID,
86+
}, nil
87+
}
88+
89+
func (a *acpEchoAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) {
90+
// Extract text from prompt
91+
var promptText string
92+
for _, block := range params.Prompt {
93+
if block.Text != nil {
94+
promptText = block.Text.Text
95+
break
96+
}
97+
}
98+
promptText = strings.TrimSpace(promptText)
99+
100+
if a.scriptIndex >= len(a.script) {
101+
return acp.PromptResponse{
102+
StopReason: acp.StopReasonEndTurn,
103+
}, nil
104+
}
105+
106+
entry := a.script[a.scriptIndex]
107+
expected := strings.TrimSpace(entry.ExpectMessage)
108+
109+
// Empty ExpectMessage matches any prompt
110+
if expected != "" && expected != promptText {
111+
return acp.PromptResponse{}, fmt.Errorf("expected message %q but got %q", expected, promptText)
112+
}
113+
114+
a.scriptIndex++
115+
116+
// Send response via session update
117+
if err := a.conn.SessionUpdate(ctx, acp.SessionNotification{
118+
SessionId: params.SessionId,
119+
Update: acp.UpdateAgentMessageText(entry.ResponseMessage),
120+
}); err != nil {
121+
return acp.PromptResponse{}, err
122+
}
123+
124+
return acp.PromptResponse{
125+
StopReason: acp.StopReasonEndTurn,
126+
}, nil
127+
}
128+
129+
func (a *acpEchoAgent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) {
130+
return acp.SetSessionModeResponse{}, nil
131+
}
132+
133+
func loadScript(scriptPath string) ([]ScriptEntry, error) {
134+
data, err := os.ReadFile(scriptPath)
135+
if err != nil {
136+
return nil, fmt.Errorf("failed to read script file: %w", err)
137+
}
138+
139+
var script []ScriptEntry
140+
if err := json.Unmarshal(data, &script); err != nil {
141+
return nil, fmt.Errorf("failed to parse script JSON: %w", err)
142+
}
143+
144+
return script, nil
145+
}

e2e/echo_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,34 @@ func TestE2E(t *testing.T) {
100100
require.Equal(t, script[0].ExpectMessage, strings.TrimSpace(msgResp.Messages[1].Content))
101101
require.Equal(t, script[0].ResponseMessage, strings.TrimSpace(msgResp.Messages[2].Content))
102102
})
103+
104+
t.Run("acp_basic", func(t *testing.T) {
105+
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
106+
defer cancel()
107+
108+
script, apiClient := setup(ctx, t, &params{
109+
cmdFn: func(ctx context.Context, t testing.TB, serverPort int, binaryPath, cwd, scriptFilePath string) (string, []string) {
110+
return binaryPath, []string{
111+
"server",
112+
fmt.Sprintf("--port=%d", serverPort),
113+
"--experimental-acp",
114+
"--", "go", "run", filepath.Join(cwd, "acp_echo.go"), scriptFilePath,
115+
}
116+
},
117+
})
118+
messageReq := agentapisdk.PostMessageParams{
119+
Content: "This is a test message.",
120+
Type: agentapisdk.MessageTypeUser,
121+
}
122+
_, err := apiClient.PostMessage(ctx, messageReq)
123+
require.NoError(t, err, "Failed to send message via SDK")
124+
require.NoError(t, waitAgentAPIStable(ctx, t, apiClient, operationTimeout, "post message"))
125+
msgResp, err := apiClient.GetMessages(ctx)
126+
require.NoError(t, err, "Failed to get messages via SDK")
127+
require.Len(t, msgResp.Messages, 2)
128+
require.Equal(t, script[0].ExpectMessage, strings.TrimSpace(msgResp.Messages[0].Content))
129+
require.Equal(t, script[0].ResponseMessage, strings.TrimSpace(msgResp.Messages[1].Content))
130+
})
103131
}
104132

105133
type params struct {

e2e/testdata/acp_basic.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[
2+
{
3+
"expectMessage": "This is a test message.",
4+
"responseMessage": "Echo: This is a test message."
5+
}
6+
]

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/ActiveState/termtest/xpty v0.6.0
77
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
88
github.com/charmbracelet/bubbletea v1.3.4
9+
github.com/coder/acp-go-sdk v0.6.3
910
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
1011
github.com/coder/quartz v0.1.2
1112
github.com/danielgtaylor/huma/v2 v2.32.0

0 commit comments

Comments
 (0)