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
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))
);
Comment on lines -307 to -309
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: this was causing a 'flicker' when sending a message in the UI

setLoading(false);
}
}
Comment on lines 306 to 309
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed lines filtered out draft messages before setting loading to false. Without this cleanup, draft messages (messages with undefined id) could remain in the messages array if an error occurs during the POST request. This could cause UI inconsistencies where draft/optimistic messages persist even though the actual message was never sent.

The draft message cleanup should be performed in the finally block (or elsewhere) to ensure consistency.

Copilot uses AI. Check for mistakes.
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"`
Backend string `json:"backend"`
}

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.Backend == "acp" {
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.")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: It should be eventually supported but I'm not sure what form it should take yet.

}

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
94 changes: 69 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 acpResult *httpapi.SetupACPResult

if printOpenAPI {
process = nil
agentIO = nil
} else if experimentalACP {
var err error
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
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,35 @@ 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 acpResult != nil {
go func() {
defer close(processExitCh)
defer close(acpResult.Done) // Signal cleanup goroutine to exit
if err := acpResult.Wait(); 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 +222,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 +271,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