diff --git a/server/pkg/agent/cursor.go b/server/pkg/agent/cursor.go index b6cebca69..b550874ec 100644 --- a/server/pkg/agent/cursor.go +++ b/server/pkg/agent/cursor.go @@ -39,7 +39,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt cmd := exec.CommandContext(runCtx, argv0, cmdArgs...) hideAgentWindow(cmd) b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs) - cmd.WaitDelay = 20 * time.Second + cmd.WaitDelay = 500 * time.Millisecond if opts.Cwd != "" { cmd.Dir = opts.Cwd } @@ -78,6 +78,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt var sessionID string finalStatus := "completed" var finalError string + resultSeen := false // stepUsage accumulates per-step token counts from "step_finish" events. // resultUsage holds authoritative session totals from "result" events. // If the result event includes usage, we use resultUsage exclusively; @@ -140,6 +141,7 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt }) case "result": + resultSeen = true if evt.IsError || evt.Subtype == "error" { finalStatus = "failed" finalError = cursorErrorText(&evt) @@ -151,6 +153,10 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt if evt.Usage != nil { hasResultUsage = true } + // Current Cursor Agent versions can emit the terminal result + // event but keep a worker process alive. Treat result as the + // protocol boundary so the daemon can report completion. + cancel() case "error": errMsg := cursorErrorText(&evt) @@ -198,10 +204,10 @@ func (b *cursorBackend) Execute(ctx context.Context, prompt string, opts ExecOpt if runCtx.Err() == context.DeadlineExceeded { finalStatus = "timeout" finalError = fmt.Sprintf("cursor-agent timed out after %s", timeout) - } else if runCtx.Err() == context.Canceled { + } else if runCtx.Err() == context.Canceled && !resultSeen { finalStatus = "aborted" finalError = "execution cancelled" - } else if exitErr != nil && finalStatus == "completed" { + } else if exitErr != nil && finalStatus == "completed" && !resultSeen { finalStatus = "failed" finalError = fmt.Sprintf("cursor-agent exited with error: %v", exitErr) } diff --git a/server/pkg/agent/cursor_execute_unix_test.go b/server/pkg/agent/cursor_execute_unix_test.go new file mode 100644 index 000000000..fa0041b05 --- /dev/null +++ b/server/pkg/agent/cursor_execute_unix_test.go @@ -0,0 +1,77 @@ +//go:build unix + +package agent + +import ( + "log/slog" + "path/filepath" + "testing" + "time" +) + +func TestCursorExecuteStopsAfterTerminalResult(t *testing.T) { + t.Parallel() + + script := `#!/bin/sh +printf '%s\n' '{"type":"system","subtype":"init","session_id":"sess-terminal"}' +printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"result":"done","session_id":"sess-terminal"}' +sleep 10 +` + result := executeFakeCursor(t, script) + + if result.Status != "completed" { + t.Fatalf("status = %q, want completed; error=%q", result.Status, result.Error) + } + if result.Output != "done" { + t.Fatalf("output = %q, want done", result.Output) + } + if result.SessionID != "sess-terminal" { + t.Fatalf("session id = %q, want sess-terminal", result.SessionID) + } +} + +func TestCursorExecuteStopsAfterTerminalErrorResult(t *testing.T) { + t.Parallel() + + script := `#!/bin/sh +printf '%s\n' '{"type":"system","subtype":"init","session_id":"sess-terminal-error"}' +printf '%s\n' '{"type":"result","subtype":"error","is_error":true,"result":"failed hard","session_id":"sess-terminal-error"}' +sleep 10 +` + result := executeFakeCursor(t, script) + + if result.Status != "failed" { + t.Fatalf("status = %q, want failed; error=%q", result.Status, result.Error) + } + if result.Error != "failed hard" { + t.Fatalf("error = %q, want failed hard", result.Error) + } + if result.Output != "failed hard" { + t.Fatalf("output = %q, want failed hard", result.Output) + } + if result.SessionID != "sess-terminal-error" { + t.Fatalf("session id = %q, want sess-terminal-error", result.SessionID) + } +} + +func executeFakeCursor(t *testing.T, script string) Result { + t.Helper() + + fakePath := filepath.Join(t.TempDir(), "cursor-agent") + writeTestExecutable(t, fakePath, []byte(script)) + + backend, err := New("cursor", Config{ExecutablePath: fakePath, Logger: slog.Default()}) + if err != nil { + t.Fatalf("New(cursor): %v", err) + } + session, err := backend.Execute(t.Context(), "hello", ExecOptions{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("Execute: %v", err) + } + + result := <-session.Result + if result.Status == "timeout" { + t.Fatalf("cursor backend timed out instead of stopping after terminal result; error=%q", result.Error) + } + return result +}