Skip to content
Draft
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
3 changes: 0 additions & 3 deletions chat/src/components/chat-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,6 @@ export function ChatProvider({ children }: PropsWithChildren) {
});
} finally {
if (type === "user") {
setMessages((prevMessages) =>
prevMessages.filter((m) => !isDraftMessage(m))
);
setLoading(false);
}
}
Expand Down
35 changes: 35 additions & 0 deletions cmd/attach/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,42 @@ func WriteRawInputOverHTTP(ctx context.Context, url string, msg string) error {
return nil
}

// statusResponse is used to parse the /status endpoint response.
type statusResponse struct {
Status string `json:"status"`
AgentType string `json:"agent_type"`
ACPMode bool `json:"acp_mode"`
}

func checkACPMode(remoteUrl string) error {
resp, err := http.Get(remoteUrl + "/status")
if err != nil {
return xerrors.Errorf("failed to check server status: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
return xerrors.Errorf("unexpected %d response from server: %s", resp.StatusCode, resp.Status)
}

var status statusResponse
if err := json.NewDecoder(resp.Body).Decode(&status); err != nil {
return xerrors.Errorf("failed to decode server status: %w", err)
}

if status.ACPMode {
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.")
}

return nil
}

func runAttach(remoteUrl string) error {
// Check if server is running in ACP mode (attach not supported)
if err := checkACPMode(remoteUrl); err != nil {
return err
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stdin := int(os.Stdin.Fd())
Expand Down
93 changes: 68 additions & 25 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/coder/agentapi/lib/httpapi"
"github.com/coder/agentapi/lib/logctx"
"github.com/coder/agentapi/lib/msgfmt"
st "github.com/coder/agentapi/lib/screentracker"
"github.com/coder/agentapi/lib/termexec"
)

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

printOpenAPI := viper.GetBool(FlagPrintOpenAPI)
experimentalACP := viper.GetBool(FlagExperimentalACP)

if printOpenAPI && experimentalACP {
return xerrors.Errorf("flags --%s and --%s are mutually exclusive", FlagPrintOpenAPI, FlagExperimentalACP)
}

var agentIO st.AgentIO
var transport = "pty"
var process *termexec.Process
var acpWait func() error

if printOpenAPI {
process = nil
agentIO = nil
} else if experimentalACP {
acpResult, err := httpapi.SetupACP(ctx, httpapi.SetupACPConfig{
Program: agent,
ProgramArgs: argsToPass[1:],
})
if err != nil {
return xerrors.Errorf("failed to setup ACP: %w", err)
}
acpIO := acpResult.AgentIO
acpWait = acpResult.Wait
agentIO = acpIO
transport = "acp"
} else {
process, err = httpapi.SetupProcess(ctx, httpapi.SetupProcessConfig{
proc, err := httpapi.SetupProcess(ctx, httpapi.SetupProcessConfig{
Program: agent,
ProgramArgs: argsToPass[1:],
TerminalWidth: termWidth,
Expand All @@ -118,11 +141,14 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
if err != nil {
return xerrors.Errorf("failed to setup process: %w", err)
}
process = proc
agentIO = proc
}
port := viper.GetInt(FlagPort)
srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{
AgentType: agentType,
Process: process,
AgentIO: agentIO,
Transport: transport,
Port: port,
ChatBasePath: viper.GetString(FlagChatBasePath),
AllowedHosts: viper.GetStringSlice(FlagAllowedHosts),
Expand All @@ -138,19 +164,34 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
}
logger.Info("Starting server on port", "port", port)
processExitCh := make(chan error, 1)
go func() {
defer close(processExitCh)
if err := process.Wait(); err != nil {
if errors.Is(err, termexec.ErrNonZeroExitCode) {
processExitCh <- xerrors.Errorf("========\n%s\n========\n: %w", strings.TrimSpace(process.ReadScreen()), err)
} else {
processExitCh <- xerrors.Errorf("failed to wait for process: %w", err)
// Wait for process exit in PTY mode
if process != nil {
go func() {
defer close(processExitCh)
if err := process.Wait(); err != nil {
if errors.Is(err, termexec.ErrNonZeroExitCode) {
processExitCh <- xerrors.Errorf("========\n%s\n========\n: %w", strings.TrimSpace(process.ReadScreen()), err)
} else {
processExitCh <- xerrors.Errorf("failed to wait for process: %w", err)
}
}
}
if err := srv.Stop(ctx); err != nil {
logger.Error("Failed to stop server", "error", err)
}
}()
if err := srv.Stop(ctx); err != nil {
logger.Error("Failed to stop server", "error", err)
}
}()
}
// Wait for process exit in ACP mode
if acpWait != nil {
go func() {
defer close(processExitCh)
if err := acpWait(); err != nil {
processExitCh <- xerrors.Errorf("ACP process exited: %w", err)
}
if err := srv.Stop(ctx); err != nil {
logger.Error("Failed to stop server", "error", err)
}
}()
}
if err := srv.Start(); err != nil && err != context.Canceled && err != http.ErrServerClosed {
return xerrors.Errorf("failed to start server: %w", err)
}
Expand Down Expand Up @@ -180,16 +221,17 @@ type flagSpec struct {
}

const (
FlagType = "type"
FlagPort = "port"
FlagPrintOpenAPI = "print-openapi"
FlagChatBasePath = "chat-base-path"
FlagTermWidth = "term-width"
FlagTermHeight = "term-height"
FlagAllowedHosts = "allowed-hosts"
FlagAllowedOrigins = "allowed-origins"
FlagExit = "exit"
FlagInitialPrompt = "initial-prompt"
FlagType = "type"
FlagPort = "port"
FlagPrintOpenAPI = "print-openapi"
FlagChatBasePath = "chat-base-path"
FlagTermWidth = "term-width"
FlagTermHeight = "term-height"
FlagAllowedHosts = "allowed-hosts"
FlagAllowedOrigins = "allowed-origins"
FlagExit = "exit"
FlagInitialPrompt = "initial-prompt"
FlagExperimentalACP = "experimental-acp"
)

func CreateServerCmd() *cobra.Command {
Expand Down Expand Up @@ -228,6 +270,7 @@ func CreateServerCmd() *cobra.Command {
// localhost:3284 is the default origin when you open the chat interface in your browser. localhost:3000 and 3001 are used during development.
{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"},
{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"},
{FlagExperimentalACP, "", false, "Use experimental ACP transport instead of PTY", "bool"},
}

for _, spec := range flagSpecs {
Expand Down
145 changes: 145 additions & 0 deletions e2e/acp_echo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//go:build ignore

package main

import (
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"strings"

acp "github.com/coder/acp-go-sdk"
)

// ScriptEntry defines a single entry in the test script.
type ScriptEntry struct {
ExpectMessage string `json:"expectMessage"`
ThinkDurationMS int64 `json:"thinkDurationMS"`
ResponseMessage string `json:"responseMessage"`
}

// acpEchoAgent implements the ACP Agent interface for testing.
type acpEchoAgent struct {
script []ScriptEntry
scriptIndex int
conn *acp.AgentSideConnection
sessionID acp.SessionId
}

var _ acp.Agent = (*acpEchoAgent)(nil)

func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "Usage: acp_echo <script.json>")
os.Exit(1)
}

script, err := loadScript(os.Args[1])
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading script: %v\n", err)
os.Exit(1)
}

if len(script) == 0 {
fmt.Fprintln(os.Stderr, "Script is empty")
os.Exit(1)
}

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
go func() {
<-sigCh
os.Exit(0)
}()

agent := &acpEchoAgent{
script: script,
}

conn := acp.NewAgentSideConnection(agent, os.Stdout, os.Stdin)
agent.conn = conn

<-conn.Done()
}

func (a *acpEchoAgent) Initialize(_ context.Context, _ acp.InitializeRequest) (acp.InitializeResponse, error) {
return acp.InitializeResponse{
ProtocolVersion: acp.ProtocolVersionNumber,
AgentCapabilities: acp.AgentCapabilities{},
}, nil
}

func (a *acpEchoAgent) Authenticate(_ context.Context, _ acp.AuthenticateRequest) (acp.AuthenticateResponse, error) {
return acp.AuthenticateResponse{}, nil
}

func (a *acpEchoAgent) Cancel(_ context.Context, _ acp.CancelNotification) error {
return nil
}

func (a *acpEchoAgent) NewSession(_ context.Context, _ acp.NewSessionRequest) (acp.NewSessionResponse, error) {
a.sessionID = "test-session"
return acp.NewSessionResponse{
SessionId: a.sessionID,
}, nil
}

func (a *acpEchoAgent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.PromptResponse, error) {
// Extract text from prompt
var promptText string
for _, block := range params.Prompt {
if block.Text != nil {
promptText = block.Text.Text
break
}
}
promptText = strings.TrimSpace(promptText)

if a.scriptIndex >= len(a.script) {
return acp.PromptResponse{
StopReason: acp.StopReasonEndTurn,
}, nil
}

entry := a.script[a.scriptIndex]
expected := strings.TrimSpace(entry.ExpectMessage)

// Empty ExpectMessage matches any prompt
if expected != "" && expected != promptText {
return acp.PromptResponse{}, fmt.Errorf("expected message %q but got %q", expected, promptText)
}

a.scriptIndex++

// Send response via session update
if err := a.conn.SessionUpdate(ctx, acp.SessionNotification{
SessionId: params.SessionId,
Update: acp.UpdateAgentMessageText(entry.ResponseMessage),
}); err != nil {
return acp.PromptResponse{}, err
}

return acp.PromptResponse{
StopReason: acp.StopReasonEndTurn,
}, nil
}

func (a *acpEchoAgent) SetSessionMode(_ context.Context, _ acp.SetSessionModeRequest) (acp.SetSessionModeResponse, error) {
return acp.SetSessionModeResponse{}, nil
}

func loadScript(scriptPath string) ([]ScriptEntry, error) {
data, err := os.ReadFile(scriptPath)
if err != nil {
return nil, fmt.Errorf("failed to read script file: %w", err)
}

var script []ScriptEntry
if err := json.Unmarshal(data, &script); err != nil {
return nil, fmt.Errorf("failed to parse script JSON: %w", err)
}

return script, nil
}
28 changes: 28 additions & 0 deletions e2e/echo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,34 @@ func TestE2E(t *testing.T) {
require.Equal(t, script[0].ExpectMessage, strings.TrimSpace(msgResp.Messages[1].Content))
require.Equal(t, script[0].ResponseMessage, strings.TrimSpace(msgResp.Messages[2].Content))
})

t.Run("acp_basic", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()

script, apiClient := setup(ctx, t, &params{
cmdFn: func(ctx context.Context, t testing.TB, serverPort int, binaryPath, cwd, scriptFilePath string) (string, []string) {
return binaryPath, []string{
"server",
fmt.Sprintf("--port=%d", serverPort),
"--experimental-acp",
"--", "go", "run", filepath.Join(cwd, "acp_echo.go"), scriptFilePath,
}
},
})
messageReq := agentapisdk.PostMessageParams{
Content: "This is a test message.",
Type: agentapisdk.MessageTypeUser,
}
_, err := apiClient.PostMessage(ctx, messageReq)
require.NoError(t, err, "Failed to send message via SDK")
require.NoError(t, waitAgentAPIStable(ctx, t, apiClient, operationTimeout, "post message"))
msgResp, err := apiClient.GetMessages(ctx)
require.NoError(t, err, "Failed to get messages via SDK")
require.Len(t, msgResp.Messages, 2)
require.Equal(t, script[0].ExpectMessage, strings.TrimSpace(msgResp.Messages[0].Content))
require.Equal(t, script[0].ResponseMessage, strings.TrimSpace(msgResp.Messages[1].Content))
})
}

type params struct {
Expand Down
6 changes: 6 additions & 0 deletions e2e/testdata/acp_basic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{
"expectMessage": "This is a test message.",
"responseMessage": "Echo: This is a test message."
}
]
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/ActiveState/termtest/xpty v0.6.0
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d
github.com/charmbracelet/bubbletea v1.3.4
github.com/coder/acp-go-sdk v0.6.3
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225
github.com/coder/quartz v0.1.2
github.com/danielgtaylor/huma/v2 v2.32.0
Expand Down
Loading