Skip to content

Commit 410e29b

Browse files
committed
Merge branch 'main' into 35C4n0r/agentapi-state-persistence
# Conflicts: # cmd/server/server.go # e2e/echo_test.go # lib/httpapi/server.go # lib/httpapi/setup.go
2 parents b2cbf56 + 5881f7d commit 410e29b

18 files changed

Lines changed: 1776 additions & 32 deletions

File tree

chat/src/components/chat-provider.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,10 +328,9 @@ export function ChatProvider({ children }: PropsWithChildren) {
328328
description: message,
329329
});
330330
} finally {
331+
// Remove optimistic draft message if still present (may have been replaced by server response via SSE).
332+
setMessages((prev) => prev.filter((m) => !isDraftMessage(m)));
331333
if (type === "user") {
332-
setMessages((prevMessages) =>
333-
prevMessages.filter((m) => !isDraftMessage(m))
334-
);
335334
setLoading(false);
336335
}
337336
}

cmd/attach/attach.go

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

132-
func runAttach(remoteUrl string) error {
132+
func checkACPMode(remoteURL string) (bool, error) {
133+
resp, err := http.Get(remoteURL + "/status")
134+
if err != nil {
135+
return false, xerrors.Errorf("failed to check server status: %w", err)
136+
}
137+
defer func() { _ = resp.Body.Close() }()
138+
139+
if resp.StatusCode != http.StatusOK {
140+
return false, xerrors.Errorf("unexpected %d response from server: %s", resp.StatusCode, resp.Status)
141+
}
142+
143+
var status httpapi.StatusResponse
144+
if err := json.NewDecoder(resp.Body).Decode(&status.Body); err != nil {
145+
return false, xerrors.Errorf("failed to decode server status: %w", err)
146+
}
147+
148+
return status.Body.Transport == httpapi.TransportACP, nil
149+
}
150+
151+
func runAttach(remoteURL string) error {
152+
// Check if server is running in ACP mode (attach not supported)
153+
if isACP, err := checkACPMode(remoteURL); err != nil {
154+
_, _ = fmt.Fprintf(os.Stderr, "WARN: Unable to check server: %s", err.Error())
155+
} else if isACP {
156+
return xerrors.New("attach is not yet supported in ACP mode")
157+
}
158+
133159
ctx, cancel := context.WithCancel(context.Background())
134160
defer cancel()
135161
stdin := int(os.Stdin.Fd())
@@ -152,7 +178,7 @@ func runAttach(remoteUrl string) error {
152178
readScreenErrCh := make(chan error, 1)
153179
go func() {
154180
defer close(readScreenErrCh)
155-
if err := ReadScreenOverHTTP(ctx, remoteUrl+"/internal/screen", screenCh); err != nil {
181+
if err := ReadScreenOverHTTP(ctx, remoteURL+"/internal/screen", screenCh); err != nil {
156182
if errors.Is(err, context.Canceled) {
157183
return
158184
}
@@ -175,7 +201,7 @@ func runAttach(remoteUrl string) error {
175201
if input == "\x03" {
176202
continue
177203
}
178-
if err := WriteRawInputOverHTTP(ctx, remoteUrl+"/message", input); err != nil {
204+
if err := WriteRawInputOverHTTP(ctx, remoteURL+"/message", input); err != nil {
179205
writeRawInputErrCh <- xerrors.Errorf("failed to write raw input: %w", err)
180206
return
181207
}

cmd/server/server.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/coder/agentapi/lib/httpapi"
2424
"github.com/coder/agentapi/lib/logctx"
2525
"github.com/coder/agentapi/lib/msgfmt"
26+
st "github.com/coder/agentapi/lib/screentracker"
2627
"github.com/coder/agentapi/lib/termexec"
2728
)
2829

@@ -145,11 +146,33 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
145146
}
146147

147148
printOpenAPI := viper.GetBool(FlagPrintOpenAPI)
149+
experimentalACP := viper.GetBool(FlagExperimentalACP)
150+
151+
if printOpenAPI && experimentalACP {
152+
return xerrors.Errorf("flags --%s and --%s are mutually exclusive", FlagPrintOpenAPI, FlagExperimentalACP)
153+
}
154+
155+
var agentIO st.AgentIO
156+
transport := "pty"
148157
var process *termexec.Process
158+
var acpResult *httpapi.SetupACPResult
159+
149160
if printOpenAPI {
150-
process = nil
161+
agentIO = nil
162+
} else if experimentalACP {
163+
var err error
164+
acpResult, err = httpapi.SetupACP(ctx, httpapi.SetupACPConfig{
165+
Program: agent,
166+
ProgramArgs: argsToPass[1:],
167+
})
168+
if err != nil {
169+
return xerrors.Errorf("failed to setup ACP: %w", err)
170+
}
171+
acpIO := acpResult.AgentIO
172+
agentIO = acpIO
173+
transport = "acp"
151174
} else {
152-
process, err = httpapi.SetupProcess(ctx, httpapi.SetupProcessConfig{
175+
proc, err := httpapi.SetupProcess(ctx, httpapi.SetupProcessConfig{
153176
Program: agent,
154177
ProgramArgs: argsToPass[1:],
155178
TerminalWidth: termWidth,
@@ -159,11 +182,14 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
159182
if err != nil {
160183
return xerrors.Errorf("failed to setup process: %w", err)
161184
}
185+
process = proc
186+
agentIO = proc
162187
}
163188
port := viper.GetInt(FlagPort)
164189
srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{
165190
AgentType: agentType,
166-
Process: process,
191+
AgentIO: agentIO,
192+
Transport: httpapi.Transport(transport),
167193
Port: port,
168194
ChatBasePath: viper.GetString(FlagChatBasePath),
169195
AllowedHosts: viper.GetStringSlice(FlagAllowedHosts),
@@ -195,6 +221,7 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
195221

196222
// Monitor process exit
197223
processExitCh := make(chan error, 1)
224+
if process != nil {
198225
go func() {
199226
defer close(processExitCh)
200227
defer gracefulCancel()
@@ -206,6 +233,19 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
206233
}
207234
}
208235
}()
236+
}
237+
if acpResult != nil {
238+
go func() {
239+
defer close(processExitCh)
240+
defer close(acpResult.Done) // Signal cleanup goroutine to exit
241+
if err := acpResult.Wait(); err != nil {
242+
processExitCh <- xerrors.Errorf("ACP process exited: %w", err)
243+
}
244+
if err := srv.Stop(ctx); err != nil {
245+
logger.Error("Failed to stop server", "error", err)
246+
}
247+
}()
248+
}
209249

210250
// Start the server
211251
serverErrCh := make(chan error, 1)
@@ -336,6 +376,7 @@ const (
336376
FlagLoadState = "load-state"
337377
FlagSaveState = "save-state"
338378
FlagPidFile = "pid-file"
379+
FlagExperimentalACP = "experimental-acp"
339380
)
340381

341382
func CreateServerCmd() *cobra.Command {
@@ -378,6 +419,7 @@ func CreateServerCmd() *cobra.Command {
378419
{FlagLoadState, "", false, "Load state from state-file on startup (defaults to true when state-file is set)", "bool"},
379420
{FlagSaveState, "", false, "Save state to state-file on shutdown (defaults to true when state-file is set)", "bool"},
380421
{FlagPidFile, "", "", "Path to file where the server process ID will be written for shutdown scripts", "string"},
422+
{FlagExperimentalACP, "", false, "Use experimental ACP transport instead of PTY", "bool"},
381423
}
382424

383425
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
@@ -269,6 +269,34 @@ func TestE2E(t *testing.T) {
269269
require.Equal(t, "Echo: Different initial prompt", strings.TrimSpace(msgResp2.Messages[4].Content))
270270

271271
})
272+
273+
t.Run("acp_basic", func(t *testing.T) {
274+
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
275+
defer cancel()
276+
277+
script, apiClient := setup(ctx, t, &params{
278+
cmdFn: func(ctx context.Context, t testing.TB, serverPort int, binaryPath, cwd, scriptFilePath string) (string, []string) {
279+
return binaryPath, []string{
280+
"server",
281+
fmt.Sprintf("--port=%d", serverPort),
282+
"--experimental-acp",
283+
"--", "go", "run", filepath.Join(cwd, "acp_echo.go"), scriptFilePath,
284+
}
285+
},
286+
})
287+
messageReq := agentapisdk.PostMessageParams{
288+
Content: "This is a test message.",
289+
Type: agentapisdk.MessageTypeUser,
290+
}
291+
_, err := apiClient.PostMessage(ctx, messageReq)
292+
require.NoError(t, err, "Failed to send message via SDK")
293+
require.NoError(t, waitAgentAPIStable(ctx, t, apiClient, operationTimeout, "post message"))
294+
msgResp, err := apiClient.GetMessages(ctx)
295+
require.NoError(t, err, "Failed to get messages via SDK")
296+
require.Len(t, msgResp.Messages, 2)
297+
require.Equal(t, script[0].ExpectMessage, strings.TrimSpace(msgResp.Messages[0].Content))
298+
require.Equal(t, script[0].ResponseMessage, strings.TrimSpace(msgResp.Messages[1].Content))
299+
})
272300
}
273301

274302
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: 2 additions & 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
@@ -15,6 +16,7 @@ require (
1516
github.com/spf13/viper v1.20.1
1617
github.com/stretchr/testify v1.11.1
1718
github.com/tmaxmax/go-sse v0.10.0
19+
go.uber.org/goleak v1.3.0
1820
golang.org/x/term v0.30.0
1921
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
2022
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ github.com/ckaznocha/intrange v0.3.1 h1:j1onQyXvHUsPWujDH6WIjhyH26gkRt/txNlV7Lsp
163163
github.com/ckaznocha/intrange v0.3.1/go.mod h1:QVepyz1AkUoFQkpEqksSYpNpUo3c5W7nWh/s6SHIJJk=
164164
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
165165
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
166+
github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ=
167+
github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko=
166168
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
167169
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
168170
github.com/coder/paralleltestctx v0.0.1 h1:eauyehej1XYTGwgzGWMTjeRIVgOpU6XLPNVb2oi6kDs=

0 commit comments

Comments
 (0)