From c8b0b50ff8752202b72c2878bb97a9e18c438376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 21 Aug 2026 16:36:30 +0100 Subject: [PATCH] feat(acp): surface Cursor subagent metadata via cursor/task (#2867) --- apps/backend/cmd/acpdbg/main.go | 14 +- apps/backend/go.mod | 2 +- apps/backend/go.sum | 4 +- apps/backend/internal/agent/acpdbg/ops.go | 24 +++ .../internal/agent/acpdbg/ops_linger_test.go | 95 +++++++++++ .../internal/agentctl/server/acp/client.go | 44 +++++ .../agentctl/server/acp/client_test.go | 67 ++++++++ .../agentctl/server/acp/connection_test.go | 120 ++++++++++++++ .../server/adapter/transport/acp/adapter.go | 10 ++ .../adapter/transport/acp/adapter_cursor.go | 89 +++++++++++ .../adapter/transport/acp/adapter_prompt.go | 4 + .../adapter/transport/acp/adapter_session.go | 2 + .../adapter/transport/acp/adapter_tools.go | 1 + .../adapter/transport/acp/dialect_cursor.go | 69 ++++++++ .../transport/acp/dialect_cursor_test.go | 151 ++++++++++++++++++ .../server/adapter/transport/acp/subagent.go | 17 +- .../adapter/transport/acp/subagent_test.go | 43 +++++ ...client-non-underscore-extension-methods.md | 78 +++++++++ docs/decisions/INDEX.md | 1 + docs/plans/cursor-subagent-metadata/plan.md | 130 +++++++++++++++ .../task-01-sdk-extension-seam.md | 70 ++++++++ .../task-02-parse-correlate-cursor-task.md | 77 +++++++++ .../task-03-frontend-confirm.md | 61 +++++++ .../task-04-manual-live-verify.md | 52 ++++++ docs/specs/agents/cursor-subagent-metadata.md | 148 +++++++++++++++++ 25 files changed, 1362 insertions(+), 11 deletions(-) create mode 100644 apps/backend/internal/agent/acpdbg/ops_linger_test.go create mode 100644 apps/backend/internal/agentctl/server/acp/connection_test.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_cursor.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor.go create mode 100644 apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor_test.go create mode 100644 docs/decisions/2026-08-20-acp-client-non-underscore-extension-methods.md create mode 100644 docs/plans/cursor-subagent-metadata/plan.md create mode 100644 docs/plans/cursor-subagent-metadata/task-01-sdk-extension-seam.md create mode 100644 docs/plans/cursor-subagent-metadata/task-02-parse-correlate-cursor-task.md create mode 100644 docs/plans/cursor-subagent-metadata/task-03-frontend-confirm.md create mode 100644 docs/plans/cursor-subagent-metadata/task-04-manual-live-verify.md create mode 100644 docs/specs/agents/cursor-subagent-metadata.md diff --git a/apps/backend/cmd/acpdbg/main.go b/apps/backend/cmd/acpdbg/main.go index 5f4ab998ef..7063492696 100644 --- a/apps/backend/cmd/acpdbg/main.go +++ b/apps/backend/cmd/acpdbg/main.go @@ -73,7 +73,7 @@ Usage: acpdbg probe [flags] acpdbg probe --exec " [args...]" [flags] acpdbg mcp-probe [flags] - acpdbg prompt --prompt TEXT [--model M] [--mode M] [flags] + acpdbg prompt --prompt TEXT [--model M] [--mode M] [--linger DUR] [flags] acpdbg session-load --session-id ID [--prompt TEXT] [flags] acpdbg matrix [flags] @@ -240,12 +240,19 @@ func runPrompt(ctx context.Context, args []string) error { model := fs.String("model", "", "model to set before prompting") mode := fs.String("mode", "", "session mode to set before prompting") prompt := fs.String("prompt", "", "prompt text (required)") + linger := fs.Duration("linger", 0, "keep the session open this long after the prompt returns, recording any late frames; extends the run beyond --timeout (must be >= 0)") if err := fs.Parse(args); err != nil { return err } if *prompt == "" { return errors.New("--prompt is required") } + if *linger < 0 { + // A negative linger would shrink the run deadline below --timeout and + // could cancel the prompt before it completes. The linger window only + // ever extends the run. + return errors.New("--linger must not be negative") + } cfg, err := resolveRunConfig(fs, shared, "prompt") if err != nil { @@ -253,7 +260,9 @@ func runPrompt(ctx context.Context, args []string) error { } jsonlPath := resolveJSONLPath(shared, cfg.AgentID, "prompt") - runCtx, cancel := context.WithTimeout(ctx, shared.timeout) + // The run deadline has to cover the prompt turn plus the linger window, + // otherwise the shared --timeout would cancel the session mid-linger. + runCtx, cancel := context.WithTimeout(ctx, shared.timeout+*linger) defer cancel() runner, err := acpdbg.NewRunner(runCtx, jsonlPath, cfg) @@ -266,6 +275,7 @@ func runPrompt(ctx context.Context, args []string) error { Model: *model, Mode: *mode, Prompt: *prompt, + Linger: *linger, }) if err != nil { fmt.Fprintf(os.Stderr, "prompt failed: %v\n", err) diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 828cad22d3..c1b0a0b328 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -124,4 +124,4 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect ) -replace github.com/coder/acp-go-sdk => github.com/kdlbs/acp-go-sdk v0.13.6-0.20260722160645-1ce4653527f6 +replace github.com/coder/acp-go-sdk => github.com/kdlbs/acp-go-sdk v0.13.6-0.20260821092416-13e3f4dc12c2 diff --git a/apps/backend/go.sum b/apps/backend/go.sum index c70d7af216..abcb22a6b4 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -229,8 +229,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kdlbs/acp-go-sdk v0.13.6-0.20260722160645-1ce4653527f6 h1:PIUaLxC1PZ4Y1ESMzucH2ThVq4yOAH8x8VfCKRSX5pc= -github.com/kdlbs/acp-go-sdk v0.13.6-0.20260722160645-1ce4653527f6/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= +github.com/kdlbs/acp-go-sdk v0.13.6-0.20260821092416-13e3f4dc12c2 h1:aWN8W+iAeSlt5iIf+YwzyZ55VevQO0jKp6TvhL818vs= +github.com/kdlbs/acp-go-sdk v0.13.6-0.20260821092416-13e3f4dc12c2/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= diff --git a/apps/backend/internal/agent/acpdbg/ops.go b/apps/backend/internal/agent/acpdbg/ops.go index 8c19bbb6d9..9ed7d2f403 100644 --- a/apps/backend/internal/agent/acpdbg/ops.go +++ b/apps/backend/internal/agent/acpdbg/ops.go @@ -111,6 +111,11 @@ type PromptOptions struct { Model string // if non-empty, set via session/set_model (or unstable variant) Mode string // if non-empty, set via session/set_mode Prompt string // required — text body of the session/prompt request + // Linger, when > 0, keeps the session open after session/prompt returns + // its stopReason and drains (recording, auto-replying to) any further + // out-of-band frames the agent pushes. This is how we observe a delayed + // async subagent result delivered on the same session after end_turn. + Linger time.Duration } // PromptResult holds the text response collected from session/update @@ -160,9 +165,28 @@ func Prompt(ctx context.Context, r *Runner, opts PromptOptions) (*PromptResult, if err != nil { return nil, fmt.Errorf("session/prompt: %w", err) } + + if opts.Linger > 0 { + lingerAfterPrompt(ctx, r, opts.Linger) + } + return &PromptResult{ProbeResult: *probe, Text: text}, nil } +// lingerAfterPrompt keeps draining out-of-band frames for the given duration +// after session/prompt has already returned its stopReason. Every frame is +// still recorded by the read loop, and NextOOB auto-replies to any +// agent-initiated request so the agent does not hang. This exists to capture a +// delayed async update (e.g. Cursor pushing a background subagent result on the +// same session after end_turn). We swallow the terminal error: a deadline or a +// child that exits first are both expected ways to stop lingering. +func lingerAfterPrompt(ctx context.Context, r *Runner, d time.Duration) { + lingerCtx, cancel := context.WithTimeout(ctx, d) + defer cancel() + // Never returns true, so it drains until the linger deadline or EOF. + _ = r.DrainOOBUntil(lingerCtx, func(Frame) bool { return false }) +} + // SessionLoad runs initialize → session/load → [session/prompt]. func SessionLoad(ctx context.Context, r *Runner, opts SessionLoadOptions) (*SessionLoadResult, error) { if opts.SessionID == "" { diff --git a/apps/backend/internal/agent/acpdbg/ops_linger_test.go b/apps/backend/internal/agent/acpdbg/ops_linger_test.go new file mode 100644 index 0000000000..abe8fbffba --- /dev/null +++ b/apps/backend/internal/agent/acpdbg/ops_linger_test.go @@ -0,0 +1,95 @@ +//go:build !windows + +package acpdbg + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// TestLingerAfterPromptDrainsDelayedFrameAndAutoReplies proves the prompt +// linger path: a frame the agent emits after session/prompt already returned is +// still drained and recorded, an agent-initiated request during the window is +// auto-replied so the agent does not hang, and the linger deadline stops the +// drain harmlessly. +func TestLingerAfterPromptDrainsDelayedFrameAndAutoReplies(t *testing.T) { + framesPath := filepath.Join(t.TempDir(), "frames.jsonl") + // Emit one late agent-initiated request (has id + method) then idle. The + // linger window must outlive the emit but the deadline must still land. + req := `{"jsonrpc":"2.0","id":99,"method":"cursor/task"}` + cmd := fmt.Sprintf("printf '%%s\\n' '%s'; sleep 30", req) + ctx, cancel := context.WithCancel(context.Background()) + runner, err := NewRunner(ctx, framesPath, RunConfig{ + AgentID: "linger-helper", + Command: []string{"sh", "-c", cmd}, + }) + if err != nil { + t.Fatalf("NewRunner: %v", err) + } + t.Cleanup(func() { + cancel() + runner.tree.kill(runner.cmd) + runner.Close("test cleanup") + }) + + start := time.Now() + lingerAfterPrompt(ctx, runner, 300*time.Millisecond) + elapsed := time.Since(start) + + // The deadline was honored: lingering returned only after the window, and + // well before the child's 30s sleep. + if elapsed < 250*time.Millisecond { + t.Fatalf("linger returned after %s, want it to honor the ~300ms window", elapsed) + } + if elapsed > 5*time.Second { + t.Fatalf("linger returned after %s, want it bounded by the window", elapsed) + } + + // The delayed request was recorded and auto-replied with method-not-found. + raw, err := os.ReadFile(framesPath) + if err != nil { + t.Fatalf("read frames: %v", err) + } + frames := string(raw) + if !strings.Contains(frames, `"cursor/task"`) { + t.Fatalf("delayed request not recorded; frames:\n%s", frames) + } + if !strings.Contains(frames, "-32601") { + t.Fatalf("delayed agent request was not auto-replied with method-not-found; frames:\n%s", frames) + } +} + +// TestLingerAfterPromptDeadlineWithNoFramesIsHarmless proves the linger window +// terminates cleanly when the agent emits nothing after the prompt. +func TestLingerAfterPromptDeadlineWithNoFramesIsHarmless(t *testing.T) { + framesPath := filepath.Join(t.TempDir(), "frames.jsonl") + ctx, cancel := context.WithCancel(context.Background()) + runner, err := NewRunner(ctx, framesPath, RunConfig{ + AgentID: "linger-idle-helper", + Command: []string{"sh", "-c", "sleep 30"}, + }) + if err != nil { + t.Fatalf("NewRunner: %v", err) + } + t.Cleanup(func() { + cancel() + runner.tree.kill(runner.cmd) + runner.Close("test cleanup") + }) + + start := time.Now() + lingerAfterPrompt(ctx, runner, 200*time.Millisecond) + elapsed := time.Since(start) + + if elapsed < 150*time.Millisecond { + t.Fatalf("linger returned after %s, want it to honor the ~200ms window", elapsed) + } + if elapsed > 5*time.Second { + t.Fatalf("linger returned after %s, want it bounded by the window", elapsed) + } +} diff --git a/apps/backend/internal/agentctl/server/acp/client.go b/apps/backend/internal/agentctl/server/acp/client.go index 55e8f6665c..bd70e07cbd 100644 --- a/apps/backend/internal/agentctl/server/acp/client.go +++ b/apps/backend/internal/agentctl/server/acp/client.go @@ -3,6 +3,7 @@ package acp import ( "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -23,6 +24,12 @@ type UpdateHandler func(notification acp.SessionNotification) // Returns the selected option ID, or empty string with cancelled=true to cancel type PermissionRequestHandler func(ctx context.Context, req *types.PermissionRequest) (*types.PermissionResponse, error) +// CursorTaskHandler is called when Cursor sends its non-standard `cursor/task` +// request with subagent metadata. The handler is fire-and-forget: it must be +// best-effort, return without surfacing errors to Cursor, and has no +// cancellation semantics, so it takes no context. +type CursorTaskHandler func(params json.RawMessage) + // Client implements acp.Client interface and handles all agent requests type Client struct { logger *zap.Logger @@ -32,6 +39,7 @@ type Client struct { mu sync.RWMutex updateHandler UpdateHandler permissionHandler PermissionRequestHandler + cursorTaskHandler CursorTaskHandler } // ClientOption configures a Client @@ -65,6 +73,13 @@ func WithPermissionHandler(h PermissionRequestHandler) ClientOption { } } +// WithCursorTaskHandler sets the handler for Cursor's `cursor/task` request. +func WithCursorTaskHandler(h CursorTaskHandler) ClientOption { + return func(c *Client) { + c.cursorTaskHandler = h + } +} + // NewClient creates a new ACP client implementation func NewClient(opts ...ClientOption) *Client { c := &Client{ @@ -92,6 +107,13 @@ func (c *Client) SetPermissionHandler(h PermissionRequestHandler) { c.permissionHandler = h } +// SetCursorTaskHandler sets the Cursor `cursor/task` handler (thread-safe). +func (c *Client) SetCursorTaskHandler(h CursorTaskHandler) { + c.mu.Lock() + defer c.mu.Unlock() + c.cursorTaskHandler = h +} + // RequestPermission handles permission requests from the agent // If a permission handler is set, it forwards the request to the handler. // Otherwise, auto-approves by selecting the first "allow" option. @@ -269,6 +291,28 @@ func (c *Client) SessionUpdate(ctx context.Context, n acp.SessionNotification) e return nil } +// HandleExtensionMethod accepts explicitly-supported inbound extension-style +// requests from the agent. Cursor currently sends subagent metadata over the +// non-standard vendor method `cursor/task`; unknown methods still decline with +// method-not-found. The ctx satisfies the SDK's ExtensionMethodHandler +// signature; the fire-and-forget cursor/task handler does not consume it. +func (c *Client) HandleExtensionMethod(_ context.Context, method string, params json.RawMessage) (any, error) { + if method != cursorTaskMethod { + return nil, acp.NewMethodNotFound(method) + } + + c.mu.RLock() + handler := c.cursorTaskHandler + c.mu.RUnlock() + if handler != nil { + handler(params) + } + + return struct{}{}, nil +} + +const cursorTaskMethod = "cursor/task" + // resolvePath resolves a file path, making relative paths relative to the workspace root. // It validates that the resolved path stays within the workspace root to prevent path traversal. func (c *Client) resolvePath(reqPath string) (string, error) { diff --git a/apps/backend/internal/agentctl/server/acp/client_test.go b/apps/backend/internal/agentctl/server/acp/client_test.go index 4390e5f5e4..0b5401c099 100644 --- a/apps/backend/internal/agentctl/server/acp/client_test.go +++ b/apps/backend/internal/agentctl/server/acp/client_test.go @@ -2,6 +2,8 @@ package acp import ( "context" + "encoding/json" + "errors" "path/filepath" "reflect" "testing" @@ -217,3 +219,68 @@ func TestForwardPermissionRequestTitleDerivation(t *testing.T) { }) } } + +func TestHandleExtensionMethod(t *testing.T) { + tests := []struct { + name string + method string + params json.RawMessage + wantErr bool + wantCalled bool + }{ + { + name: "cursor task succeeds and forwards params", + method: cursorTaskMethod, + params: json.RawMessage(`{"toolCallId":"tool-1","prompt":"summarize"}`), + wantCalled: true, + }, + { + name: "unknown method returns method not found", + method: "foo/bar", + params: json.RawMessage(`{"x":1}`), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var called bool + var gotParams json.RawMessage + client := NewClient(WithCursorTaskHandler(func(params json.RawMessage) { + called = true + gotParams = append(json.RawMessage(nil), params...) + })) + + resp, err := client.HandleExtensionMethod(context.Background(), tt.method, tt.params) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + var reqErr *acp.RequestError + if !errors.As(err, &reqErr) { + t.Fatalf("error type = %T, want *acp.RequestError", err) + } + if reqErr.Code != -32601 { + t.Fatalf("error code = %d, want -32601", reqErr.Code) + } + if called { + t.Fatal("handler was called for unknown method") + } + return + } + + if err != nil { + t.Fatalf("HandleExtensionMethod returned error: %v", err) + } + if !reflect.DeepEqual(resp, struct{}{}) { + t.Fatalf("response = %#v, want empty success object", resp) + } + if called != tt.wantCalled { + t.Fatalf("handler called = %v, want %v", called, tt.wantCalled) + } + if !reflect.DeepEqual(gotParams, tt.params) { + t.Fatalf("params = %s, want %s", gotParams, tt.params) + } + }) + } +} diff --git a/apps/backend/internal/agentctl/server/acp/connection_test.go b/apps/backend/internal/agentctl/server/acp/connection_test.go new file mode 100644 index 0000000000..4d3f89d883 --- /dev/null +++ b/apps/backend/internal/agentctl/server/acp/connection_test.go @@ -0,0 +1,120 @@ +package acp + +import ( + "bufio" + "encoding/json" + "io" + "reflect" + "testing" + "time" + + acpsdk "github.com/coder/acp-go-sdk" +) + +func TestClientSideConnection_InterceptsCursorTaskRequest(t *testing.T) { + clientToAgentReader, clientToAgentWriter := io.Pipe() + agentToClientReader, agentToClientWriter := io.Pipe() + t.Cleanup(func() { + _ = clientToAgentReader.Close() + _ = clientToAgentWriter.Close() + _ = agentToClientReader.Close() + _ = agentToClientWriter.Close() + }) + + paramsCh := make(chan json.RawMessage, 1) + _ = acpsdk.NewClientSideConnection( + NewClient(WithCursorTaskHandler(func(params json.RawMessage) { + paramsCh <- append(json.RawMessage(nil), params...) + })), + clientToAgentWriter, + agentToClientReader, + ) + + if err := writeJSONRPCLine(agentToClientWriter, map[string]any{ + "jsonrpc": "2.0", + "id": 7, + "method": cursorTaskMethod, + "params": map[string]any{ + "toolCallId": "tool-1", + "prompt": "summarize", + }, + }); err != nil { + t.Fatalf("write cursor/task request: %v", err) + } + + response := readJSONRPCLine(t, clientToAgentReader) + if !reflect.DeepEqual(response["result"], map[string]any{}) { + t.Fatalf("response result = %#v, want empty success object", response["result"]) + } + if response["error"] != nil { + t.Fatalf("response error = %#v, want nil", response["error"]) + } + + select { + case got := <-paramsCh: + var gotParams map[string]any + if err := json.Unmarshal(got, &gotParams); err != nil { + t.Fatalf("unmarshal params %q: %v", string(got), err) + } + wantParams := map[string]any{"toolCallId": "tool-1", "prompt": "summarize"} + if !reflect.DeepEqual(gotParams, wantParams) { + t.Fatalf("params = %#v, want %#v", gotParams, wantParams) + } + case <-time.After(1 * time.Second): + t.Fatal("timed out waiting for cursor/task handler") + } +} + +func TestClientSideConnection_UnknownVendorRequestReturnsMethodNotFound(t *testing.T) { + clientToAgentReader, clientToAgentWriter := io.Pipe() + agentToClientReader, agentToClientWriter := io.Pipe() + t.Cleanup(func() { + _ = clientToAgentReader.Close() + _ = clientToAgentWriter.Close() + _ = agentToClientReader.Close() + _ = agentToClientWriter.Close() + }) + + _ = acpsdk.NewClientSideConnection(NewClient(), clientToAgentWriter, agentToClientReader) + + if err := writeJSONRPCLine(agentToClientWriter, map[string]any{ + "jsonrpc": "2.0", + "id": 8, + "method": "foo/bar", + "params": map[string]any{"x": 1}, + }); err != nil { + t.Fatalf("write vendor request: %v", err) + } + + response := readJSONRPCLine(t, clientToAgentReader) + errorMap, ok := response["error"].(map[string]any) + if !ok { + t.Fatalf("response error = %#v, want JSON-RPC error object", response["error"]) + } + if errorMap["code"] != float64(-32601) { + t.Fatalf("error code = %#v, want -32601", errorMap["code"]) + } +} + +func writeJSONRPCLine(w io.Writer, payload map[string]any) error { + encoded, err := json.Marshal(payload) + if err != nil { + return err + } + encoded = append(encoded, '\n') + _, err = w.Write(encoded) + return err +} + +func readJSONRPCLine(t *testing.T, r io.Reader) map[string]any { + t.Helper() + line, err := bufio.NewReader(r).ReadBytes('\n') + if err != nil { + t.Fatalf("read response line: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(line, &decoded); err != nil { + t.Fatalf("unmarshal response %q: %v", string(line), err) + } + return decoded +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go index 54924a16eb..a33ec4f049 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go @@ -177,6 +177,13 @@ type Adapter struct { // Tool call tracking for result normalization // Maps toolCallId -> NormalizedPayload so we can update with results activeToolCalls map[string]*streams.NormalizedPayload + // cursorTaskMetaBySession stores Cursor's non-standard `cursor/task` + // metadata until the matching subagent tool_call appears, keyed by session + // then wire tool-call ID. An entry is drained either when its tool_call + // arrives (applyPendingCursorTaskMetaLocked) or when the session is cleared + // on turn end / reset (clearCursorTaskMetaLocked), so it never outlives the + // turn that produced it. + cursorTaskMetaBySession map[string]map[string]cursorTaskMeta // toolCallParents preserves nested tool lineage while a handed-off // predecessor continues streaming beside its human successor. toolCallParents map[string]string @@ -375,6 +382,7 @@ func NewAdapter(cfg *shared.Config, log *logger.Logger) *Adapter { updatesCh: make(chan AgentEvent, 100), notifQueue: make(chan notifWork, notifQueueCapacity), activeToolCalls: make(map[string]*streams.NormalizedPayload), + cursorTaskMetaBySession: make(map[string]map[string]cursorTaskMeta), toolCallParents: make(map[string]string), handoffProtectedToolCalls: make(map[string]struct{}), activeMonitors: make(map[string]map[string]string), @@ -437,6 +445,7 @@ func (a *Adapter) Initialize(ctx context.Context) error { acpclient.WithWorkspaceRoot(a.cfg.WorkDir), acpclient.WithUpdateHandler(a.enqueueACPUpdate), acpclient.WithPermissionHandler(a.handlePermissionRequest), + acpclient.WithCursorTaskHandler(a.handleCursorTask), ) // Create ACP SDK connection. Raise the inbound notification queue cap @@ -683,6 +692,7 @@ func (a *Adapter) Close() error { a.updateSendWg.Wait() a.mu.Lock() a.clearCodexSubagentCorrelationsLocked("") + a.clearCursorTaskMetaLocked("") a.clearPromptHandoffToolTrackingLocked() clear(a.usageBySession) a.mu.Unlock() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_cursor.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_cursor.go new file mode 100644 index 0000000000..b7095cdcec --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_cursor.go @@ -0,0 +1,89 @@ +package acp + +import ( + "encoding/json" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func (a *Adapter) handleCursorTask(params json.RawMessage) { + meta := parseCursorTaskParams(params) + if meta.ToolCallID == "" { + return + } + + a.mu.Lock() + sessionID := a.sessionID + var emitted *streams.NormalizedPayload + if active := a.activeToolCalls[meta.ToolCallID]; active != nil && active.Kind() == streams.ToolKindSubagentTask { + applyCursorTaskMeta(active.SubagentTask(), meta) + emitted = cloneSubagentPayload(active) + } else if sessionID != "" { + a.storeCursorTaskMetaLocked(sessionID, meta) + } + a.mu.Unlock() + + if emitted != nil && sessionID != "" { + a.sendUpdate(AgentEvent{ + Type: streams.EventTypeToolUpdate, + SessionID: sessionID, + ToolCallID: meta.ToolCallID, + ToolStatus: toolStatusInProgress, + NormalizedPayload: emitted, + }) + } +} + +func (a *Adapter) applyPendingCursorTaskMetaLocked(sessionID, toolCallID string, payload *streams.NormalizedPayload) { + if payload == nil || payload.Kind() != streams.ToolKindSubagentTask { + return + } + sessionMeta := a.cursorTaskMetaBySession[sessionID] + if sessionMeta == nil { + return + } + meta, ok := sessionMeta[toolCallID] + if !ok { + return + } + applyCursorTaskMeta(payload.SubagentTask(), meta) + delete(sessionMeta, toolCallID) + if len(sessionMeta) == 0 { + delete(a.cursorTaskMetaBySession, sessionID) + } +} + +func (a *Adapter) storeCursorTaskMetaLocked(sessionID string, meta cursorTaskMeta) { + if sessionID == "" || meta.ToolCallID == "" { + return + } + if a.cursorTaskMetaBySession == nil { + a.cursorTaskMetaBySession = make(map[string]map[string]cursorTaskMeta) + } + if a.cursorTaskMetaBySession[sessionID] == nil { + a.cursorTaskMetaBySession[sessionID] = make(map[string]cursorTaskMeta) + } + stored := a.cursorTaskMetaBySession[sessionID][meta.ToolCallID] + a.cursorTaskMetaBySession[sessionID][meta.ToolCallID] = mergeCursorTaskMeta(stored, meta) +} + +// sweepCursorTaskMetaOnPromptEnd drops any pending cursor/task metadata for the +// session that never matched a subagent tool_call during the turn. The entries +// are keyed by wire tool-call ID, which Cursor can reuse across turns, so an +// unmatched entry left behind would attach to an unrelated subagent later. +func (a *Adapter) sweepCursorTaskMetaOnPromptEnd(sessionID string) { + if sessionID == "" { + return + } + a.mu.Lock() + a.clearCursorTaskMetaLocked(sessionID) + a.mu.Unlock() +} + +func (a *Adapter) clearCursorTaskMetaLocked(sessionID string) { + if sessionID == "" { + clear(a.cursorTaskMetaBySession) + return + } + delete(a.cursorTaskMetaBySession, sessionID) +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go index 1ace0a0c20..ff4129c347 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go @@ -239,6 +239,10 @@ func (a *Adapter) sendPrompt( // state when the parent prompt completes naturally. a.sweepMonitorsOnPromptEnd(sessionID) + // Drop any Cursor `cursor/task` metadata for this session that never matched + // a subagent tool_call this turn. + a.sweepCursorTaskMetaOnPromptEnd(sessionID) + if a.agentID == codexAgentID && turn.codexCapacityFailure() { const safeMessage = codexModelCapacityErrorMessage a.logger.Info("codex prompt ended with model-capacity evidence", diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go index 35c4d0c6c3..0925b80f0c 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go @@ -40,6 +40,7 @@ func (a *Adapter) NewSession(ctx context.Context, mcpServers []types.McpServer) a.mu.Lock() a.pendingWakeups = make(map[string]*pendingWakeup) a.clearCodexSubagentCorrelationsLocked("") + a.clearCursorTaskMetaLocked("") a.clearPromptHandoffToolTrackingLocked() clear(a.usageBySession) a.wakeup.cancel() @@ -400,6 +401,7 @@ func (a *Adapter) LoadSession(ctx context.Context, sessionID string, mcpServers a.mu.Lock() a.pendingWakeups = make(map[string]*pendingWakeup) a.clearCodexSubagentCorrelationsLocked("") + a.clearCursorTaskMetaLocked("") a.clearPromptHandoffToolTrackingLocked() a.wakeup.cancel() a.mu.Unlock() diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go index e7da6d5005..949750fc9d 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go @@ -200,6 +200,7 @@ func (a *Adapter) trackToolCallPayload( a.mu.Lock() defer a.mu.Unlock() if signal == codexSubagentSignalNone { + a.applyPendingCursorTaskMetaLocked(sessionID, toolCallID, payload) a.activeToolCalls[toolCallID] = payload return payload, eventType, signal, toolCallID, "", false } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor.go new file mode 100644 index 0000000000..7b614f3876 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor.go @@ -0,0 +1,69 @@ +package acp + +import ( + "encoding/json" + + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +type cursorTaskMeta struct { + ToolCallID string + AgentID string + Description string + Model string + Prompt string +} + +func parseCursorTaskParams(params json.RawMessage) cursorTaskMeta { + if len(params) == 0 { + return cursorTaskMeta{} + } + var raw map[string]any + if err := json.Unmarshal(params, &raw); err != nil { + return cursorTaskMeta{} + } + return cursorTaskMeta{ + ToolCallID: stringFromAnyMap(raw, "toolCallId"), + AgentID: stringFromAnyMap(raw, "agentId"), + Description: stringFromAnyMap(raw, "description"), + Model: stringFromAnyMap(raw, "model"), + Prompt: stringFromAnyMap(raw, "prompt"), + } +} + +func mergeCursorTaskMeta(base, update cursorTaskMeta) cursorTaskMeta { + if base.ToolCallID == "" { + base.ToolCallID = update.ToolCallID + } + if base.AgentID == "" { + base.AgentID = update.AgentID + } + if base.Description == "" { + base.Description = update.Description + } + if base.Model == "" { + base.Model = update.Model + } + if base.Prompt == "" { + base.Prompt = update.Prompt + } + return base +} + +func applyCursorTaskMeta(payload *streams.SubagentTaskPayload, meta cursorTaskMeta) { + if payload == nil { + return + } + fillIfEmpty(&payload.Description, meta.Description) + fillIfEmpty(&payload.Prompt, meta.Prompt) + fillIfEmpty(&payload.Model, meta.Model) + fillIfEmpty(&payload.AgentID, meta.AgentID) +} + +func stringFromAnyMap(raw map[string]any, key string) string { + if raw == nil { + return "" + } + value, _ := raw[key].(string) + return value +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor_test.go new file mode 100644 index 0000000000..c19c761a90 --- /dev/null +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor_test.go @@ -0,0 +1,151 @@ +package acp + +import ( + "encoding/json" + "testing" + + acpsdk "github.com/coder/acp-go-sdk" + "github.com/kandev/kandev/internal/agentctl/types/streams" +) + +func TestParseCursorTaskParams(t *testing.T) { + tests := []struct { + name string + params string + want cursorTaskMeta + }{ + { + name: "full payload ignores object subagentType", + params: `{"agentId":"agent-1","description":"Summarize JS","model":"composer-2.5-fast",` + + `"prompt":"Summarize src JS files","subagentType":{"custom":{"unspecified":{}}},"toolCallId":"tool-1"}`, + want: cursorTaskMeta{ + ToolCallID: "tool-1", + AgentID: "agent-1", + Description: "Summarize JS", + Model: "composer-2.5-fast", + Prompt: "Summarize src JS files", + }, + }, + { + name: "missing fields stay empty", + params: `{"toolCallId":"tool-2","prompt":"Only prompt"}`, + want: cursorTaskMeta{ToolCallID: "tool-2", Prompt: "Only prompt"}, + }, + { + name: "invalid json tolerated", + params: `{`, + want: cursorTaskMeta{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := parseCursorTaskParams(json.RawMessage(tt.params)); got != tt.want { + t.Fatalf("parseCursorTaskParams() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestCursorTaskRequestBeforeToolCallMergesOnCreate(t *testing.T) { + a := newTestAdapter() + a.sessionID = "session-1" + a.handleCursorTask(json.RawMessage(`{"toolCallId":"tool-1","agentId":"agent-1","description":"Summarize JS","model":"composer-2.5-fast","prompt":"Summarize src JS files"}`)) + + event := a.convertToolCallUpdate("session-1", &acpsdk.SessionUpdateToolCall{ + ToolCallId: "tool-1", + Kind: "other", + Title: "Task: Subagent task", + Status: toolStatusInProgress, + RawInput: map[string]any{"_toolName": "task"}, + }) + if event == nil || event.Type != streams.EventTypeToolCall { + t.Fatalf("event = %+v, want initial tool_call", event) + } + sa := event.NormalizedPayload.SubagentTask() + if sa.Description != "Summarize JS" || sa.Prompt != "Summarize src JS files" || sa.Model != "composer-2.5-fast" || sa.AgentID != "agent-1" { + t.Fatalf("subagent payload = %+v", sa) + } + if pending := len(a.cursorTaskMetaBySession["session-1"]); pending != 0 { + t.Fatalf("pending cursor/task entries = %d, want 0 after match", pending) + } +} + +func TestCursorTaskRequestAfterToolCallEmitsUpdate(t *testing.T) { + a := newTestAdapter() + a.sessionID = "session-1" + start := a.convertToolCallUpdate("session-1", &acpsdk.SessionUpdateToolCall{ + ToolCallId: "tool-1", + Kind: "other", + Title: "Task: Subagent task", + Status: toolStatusInProgress, + RawInput: map[string]any{"_toolName": "task"}, + }) + if start == nil || start.Type != streams.EventTypeToolCall { + t.Fatalf("start event = %+v", start) + } + + a.handleCursorTask(json.RawMessage(`{"toolCallId":"tool-1","description":"Summarize JS","model":"composer-2.5-fast","prompt":"Summarize src JS files"}`)) + events := drainEvents(a) + if len(events) != 1 { + t.Fatalf("drained events = %d, want 1 metadata update", len(events)) + } + update := events[0] + if update.Type != streams.EventTypeToolUpdate || update.ToolCallID != "tool-1" || update.ToolStatus != toolStatusInProgress { + t.Fatalf("update event = %+v", update) + } + sa := update.NormalizedPayload.SubagentTask() + if sa.Description != "Summarize JS" || sa.Prompt != "Summarize src JS files" || sa.Model != "composer-2.5-fast" { + t.Fatalf("updated payload = %+v", sa) + } +} + +func TestCursorTaskUnmatchedMetaClearedAtPromptEnd(t *testing.T) { + a := newTestAdapter() + a.sessionID = "session-1" + + // An unmatched cursor/task arrives (no subagent tool_call this turn). + a.handleCursorTask(json.RawMessage(`{"toolCallId":"tool-1","description":"Stale","prompt":"stale prompt"}`)) + if pending := len(a.cursorTaskMetaBySession["session-1"]); pending != 1 { + t.Fatalf("pending cursor/task entries = %d, want 1 before prompt end", pending) + } + + a.sweepCursorTaskMetaOnPromptEnd("session-1") + if pending := len(a.cursorTaskMetaBySession["session-1"]); pending != 0 { + t.Fatalf("pending cursor/task entries = %d, want 0 after prompt end", pending) + } + + // A later turn reuses tool-1 for an unrelated subagent. Without the + // prompt-end sweep the stale "Stale" metadata would attach here. + event := a.convertToolCallUpdate("session-1", &acpsdk.SessionUpdateToolCall{ + ToolCallId: "tool-1", + Kind: "other", + Title: "Task: Subagent task", + Status: toolStatusInProgress, + RawInput: map[string]any{"_toolName": "task"}, + }) + if event == nil || event.Type != streams.EventTypeToolCall { + t.Fatalf("event = %+v, want initial tool_call", event) + } + if sa := event.NormalizedPayload.SubagentTask(); sa.Description == "Stale" || sa.Prompt == "stale prompt" { + t.Fatalf("stale cursor/task metadata leaked into later turn: %+v", sa) + } +} + +func TestCursorTaskCleanupDropsUnmatchedSessionState(t *testing.T) { + a := newTestAdapter() + a.mu.Lock() + a.storeCursorTaskMetaLocked("session-1", cursorTaskMeta{ToolCallID: "tool-1", Prompt: "queued"}) + a.storeCursorTaskMetaLocked("session-2", cursorTaskMeta{ToolCallID: "tool-2", Prompt: "other"}) + a.clearCursorTaskMetaLocked("session-1") + _, existsSession1 := a.cursorTaskMetaBySession["session-1"] + remaining := a.cursorTaskMetaBySession["session-2"]["tool-2"] + a.mu.Unlock() + + if existsSession1 { + t.Fatal("session-1 cursor/task cache survived targeted cleanup") + } + if remaining.ToolCallID != "tool-2" { + t.Fatalf("session-2 entry = %+v, want untouched", remaining) + } +} diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go index bcbe920c34..0822fe5a23 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go @@ -343,15 +343,20 @@ func openCodeSubagentMetadata(out map[string]any, res *SubagentTaskResult) bool return found } -// cursorSubagentResult reads Cursor's flat `rawOutput.durationMs` into res. -// Returns false when no Cursor-shaped field is present so it doesn't claim -// unrelated rawOutput maps. +// cursorSubagentResult reads Cursor's flat `rawOutput.durationMs` and +// `rawOutput.isBackground` into res. Returns false only when neither field is +// present so it doesn't claim unrelated rawOutput maps; a result carrying only +// `isBackground` still marks the subagent async. func cursorSubagentResult(out map[string]any, res *SubagentTaskResult) bool { - dur, present := out["durationMs"] - if !present { + dur, durPresent := out["durationMs"] + background, backgroundPresent := out["isBackground"].(bool) + if !durPresent && !backgroundPresent { return false } - res.DurationMs = asInt64(dur) + if durPresent { + res.DurationMs = asInt64(dur) + } + res.IsAsync = background return true } diff --git a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go index 404f80fb53..6b28f0c371 100644 --- a/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go +++ b/apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go @@ -180,6 +180,37 @@ func TestExtractSubagentResult_Cursor(t *testing.T) { if res.DurationMs != 4200 { t.Errorf("DurationMs = %d", res.DurationMs) } + if res.IsAsync { + t.Error("IsAsync = true, want false when isBackground=false") + } +} + +func TestExtractSubagentResult_CursorBackground(t *testing.T) { + rawOutput := map[string]any{"durationMs": float64(4200), "isBackground": true} + res, ok := extractSubagentResult(nil, rawOutput) + if !ok { + t.Fatal("expected Cursor result to be extracted") + } + if !res.IsAsync { + t.Error("IsAsync = false, want true when isBackground=true") + } +} + +// TestExtractSubagentResult_CursorBackgroundNoDuration covers a Cursor result +// carrying only isBackground: the async flag must still be recognized when +// durationMs is absent. +func TestExtractSubagentResult_CursorBackgroundNoDuration(t *testing.T) { + rawOutput := map[string]any{"isBackground": true} + res, ok := extractSubagentResult(nil, rawOutput) + if !ok { + t.Fatal("expected Cursor result to be extracted from isBackground alone") + } + if res.DurationMs != 0 { + t.Errorf("DurationMs = %d, want 0 when durationMs absent", res.DurationMs) + } + if !res.IsAsync { + t.Error("IsAsync = false, want true when isBackground=true without durationMs") + } } // TestExtractSubagentResult_ClaudeAsyncLaunched covers the claude-acp @@ -1484,6 +1515,18 @@ func TestEnrichSubagentResult_Cursor(t *testing.T) { if payload.SubagentTask().DurationMs != 4200 { t.Errorf("DurationMs = %d", payload.SubagentTask().DurationMs) } + if payload.SubagentTask().IsAsync { + t.Error("IsAsync = true, want false when isBackground=false") + } +} + +func TestEnrichSubagentResult_CursorBackground(t *testing.T) { + n := NewNormalizer("") + payload := streams.NewSubagentTask("", "", "") + n.EnrichSubagentResult(payload, nil, map[string]any{"durationMs": float64(4200), "isBackground": true}) + if !payload.SubagentTask().IsAsync { + t.Error("IsAsync = false, want true when isBackground=true") + } } // --- Auggie subagent detection --- diff --git a/docs/decisions/2026-08-20-acp-client-non-underscore-extension-methods.md b/docs/decisions/2026-08-20-acp-client-non-underscore-extension-methods.md new file mode 100644 index 0000000000..41765ef916 --- /dev/null +++ b/docs/decisions/2026-08-20-acp-client-non-underscore-extension-methods.md @@ -0,0 +1,78 @@ +# ADR-2026-08-20-acp-client-non-underscore-extension-methods: Route Non-Underscore Inbound Client Methods to the Extension Handler + +**Status:** accepted +**Date:** 2026-08-20 +**Area:** backend, protocol + +## Context + +ACP reserves the `_`-prefix namespace for extension methods, and the +`kdlbs/acp-go-sdk` fork only routes `_`-prefixed inbound methods to a peer's +`ExtensionMethodHandler`; every other unrecognized method falls through the +generated `handle` switch and returns `-32601` method-not-found. Cursor breaks +that assumption: it delivers rich subagent metadata over an agent-to-client +**request** named `cursor/task` (no `_` prefix), so Kandev, acting as the ACP +client, rejects it today and drops the subagent's prompt, model, description, and +agent id. Other current and future agent CLIs are likely to introduce their own +vendor-namespaced (`vendor/method`) inbound requests for the same reason, and the +adapter needs one consistent seam to accept or decline them rather than a +per-method fork patch each time. + +## Decision + +Widen the SDK fork's client-side dispatch and let Kandev's ACP `Client` own the +accept/decline policy for unrecognized inbound methods. + +- In `github.com/kdlbs/acp-go-sdk` `extensions.go`, + `ClientSideConnection.handleWithExtensions` routes **any** method the generated + `ClientSideConnection.handle` switch does not recognize to the client's + `ExtensionMethodHandler` when the `Client` implements one, instead of only + `_`-prefixed names. The `_`-prefix contract for the *outbound* + `CallExtension` / `NotifyExtension` helpers (`validateExtensionMethodName`) is + unchanged: Kandev only relaxes what it will *accept*, not what it will *send*. + The `AgentSideConnection` path is unchanged. A new pseudo-version is published + and the `replace` in `apps/backend/go.mod` is bumped to it. +- Kandev's `Client` + (`apps/backend/internal/agentctl/server/acp/client.go`) implements + `HandleExtensionMethod(ctx, method, params)`. It returns an empty success + result only for methods it explicitly recognizes (`cursor/task` first) and + returns `acp.NewMethodNotFound(method)` for everything else, so an unknown + method still declines correctly rather than being silently accepted. +- Parsing and correlation of a recognized method's params stay out of the SDK and + out of `client.go`; they live in the agent-specific dialect + (`internal/agentctl/server/adapter/transport/acp/dialect_cursor.go`), consistent + with [ADR 0044](0044-acp-agent-compatibility-dialects.md). + +## Consequences + +- Kandev can accept vendor-namespaced inbound requests from any ACP agent CLI + through one client-owned allowlist, without a fork patch per method. +- The accept/decline decision is centralized and explicit: an unrecognized method + still returns method-not-found, so the change does not blanket-accept unknown + traffic. +- The fork now diverges further from upstream `coder/acp-go-sdk` on inbound + client dispatch, so the divergence must be re-checked on every SDK rebase and + is recorded here as the reason. +- The relaxation is asymmetric (inbound accept only); outbound sends still refuse + non-`_` names, preserving spec-compliant behavior toward peers. + +## Alternatives Considered + +### Wrap the transport instead of touching the fork (plan Option B) + +Interpose a reader between agent stdout and `NewClientSideConnection` that peeks +frames, replies to `cursor/task` out-of-band on the shared writer, and passes +everything else through. Rejected: it duplicates the SDK's JSON-RPC framing and +response correctness, adds a second code path that must stay in lockstep with the +SDK, and is higher risk than a small, well-scoped fork change we already own. + +### Ask Cursor to use a `_`-prefixed method + +Not actionable: `cursor/task` is Cursor's wire contract and Kandev does not +control it. The client must adapt to observed agent behavior. + +### Handle `cursor/task` with a hardcoded branch in the SDK fork + +Rejected: it embeds one vendor's method name in a protocol library and forces a +fork bump for the next agent that does the same. Routing unrecognized methods to +the client keeps vendor knowledge in Kandev's dialect layer. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index e982a09864..47b1c0af37 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -180,3 +180,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-17-release-pr-ruleset-bypass | [Give Stable Release PRs an Administrator Token Bypass](2026-08-17-release-pr-ruleset-bypass.md) | accepted | infra, workflow, security | 2026-08-17 | | 2026-08-18-never-started-agent-stall-terminal | [Treat Never-Started Agent Stalls as Terminal](2026-08-18-never-started-agent-stall-terminal.md) | accepted | backend, frontend, protocol | 2026-08-18 | | 2026-08-14-kandev-native-agent-rich-output | [Keep Agent Rich Output Host Native](2026-08-14-kandev-native-agent-rich-output.md) | accepted | backend, frontend, protocol, security | 2026-08-14 | +| 2026-08-20-acp-client-non-underscore-extension-methods | [Route Non-Underscore Inbound Client Methods to the Extension Handler](2026-08-20-acp-client-non-underscore-extension-methods.md) | accepted | backend, protocol | 2026-08-20 | diff --git a/docs/plans/cursor-subagent-metadata/plan.md b/docs/plans/cursor-subagent-metadata/plan.md new file mode 100644 index 0000000000..4d8fb1df03 --- /dev/null +++ b/docs/plans/cursor-subagent-metadata/plan.md @@ -0,0 +1,130 @@ +--- +spec: docs/specs/agents/cursor-subagent-metadata.md +created: 2026-08-20 +status: in_progress +--- + +# Implementation Plan: Cursor Subagent Metadata + +## Overview + +Cursor delivers its subagent's prompt, model, description, and agent id over a +non-standard agent-to-client request, `cursor/task`, which Kandev currently +rejects with `-32601`. This plan (1) opens a seam so the ACP client can accept a +non-`_`-prefixed extension method, (2) parses `cursor/task` and correlates it to +the standard subagent `tool_call` by `toolCallId`, merging its fields onto the +existing `SubagentTaskPayload`, and (3) maps Cursor's `isBackground` completion +flag onto the payload's existing `IsAsync` so the current "background" chip +renders. The frontend already renders model, prompt (fallback), and the +background chip, so no frontend change is required for the golden path. + +--- + +## Resolved decision and implemented seam + +The upstream ACP client routes inbound client extension handling only for +method names starting with `_`, so `cursor/task` would fall through to the +generated `ClientSideConnection.handle` switch and return `-32601`. + +The implementation that landed widens that seam in the pinned ACP fork instead +of proxying it in-repo: the fork routes any unrecognized inbound client +*request* to the client's `ExtensionMethodHandler`, and Kandev keeps the +client-owned allow/deny policy in +`apps/backend/internal/agentctl/server/acp/client.go`. The adapter registers a +`CursorTaskHandler` via `acpclient.WithCursorTaskHandler` and passes the client +straight to `acp.NewClientSideConnection` (`adapter.go`), so there is no +repo-local connection proxy. The relaxation is inbound-accept only; the outbound +`CallExtension`/`NotifyExtension` `_`-prefix contract is unchanged. + +--- + +## Backend + +### Area 1 — client extension seam (Task 01) +Kandev implements `HandleExtensionMethod(ctx, method, params)` on +`acpclient.Client` (`apps/backend/internal/agentctl/server/acp/client.go`), +returning an empty result for `cursor/task` and `NewMethodNotFound` otherwise. +The pinned ACP fork routes any unrecognized inbound client request to that +handler, so no repo-local connection helper is needed. A `CursorTaskHandler` +option (mirroring `WithUpdateHandler`) hands the raw params to the adapter. + +### Area 2 — parse + correlate cursor/task (Task 02) +New `dialect_cursor.go` in `apps/backend/internal/agentctl/server/adapter/transport/acp/`: parse the `cursor/task` params (`agentId`, `description`, `model`, `prompt`, `toolCallId`; `subagentType` is an object and is ignored) defensively over untyped maps. The adapter keeps a per-session `map[toolCallId]cursorTaskMeta` (bounded, cleared on session teardown). On either the `cursor/task` request or the subagent `tool_call` (whichever is second), merge onto the `SubagentTaskPayload` via the existing fill-if-present rule (extend `applySubagentResult` or a sibling `applyCursorTaskMeta`). Wire the adapter's `CursorTaskHandler` in `adapter.go` next to `enqueueACPUpdate`. + +### Area 3 — background flag (Task 02, same change) +Extend `cursorSubagentResult` (`subagent.go:349`) to read `rawOutput.isBackground` and set `res.IsAsync = true` when it is `true`. This reuses `SubagentTaskPayload.IsAsync` and the frontend background chip; no new payload field. + +--- + +## Frontend + +> No change required for the golden path. `model` already renders as a chip +> (`subagent-meta.ts:85`), `is_async` already renders the "background" chip +> (`subagent-meta.ts:56`), and `prompt` already renders as the card body +> fallback when there is no `result_text` and no children +> (`tool-subagent-message.tsx`). The `SubagentTaskPayload` TS type +> (`apps/web/components/task/chat/types.ts`) already carries every field this +> feature populates. Confirm in Task 04 (E2E/manual) and only add rendering if a +> gap is observed. + +--- + +## Tests + +- **Extension dispatch (Task 01):** `cursor/task` returns a success result, not + `-32601`. File: `apps/backend/internal/agentctl/server/acp/client_test.go` + (table-driven: `cursor/task` → ok; unknown `foo/bar` → method-not-found). +- **cursor/task parse (Task 02):** parses `agentId`/`description`/`model`/ + `prompt`; ignores object `subagentType`; tolerates missing fields. File: + `apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor_test.go`. +- **Correlation both orders (Task 02):** request-before-tool_call and + tool_call-before-request both merge onto one payload; unmatched entry is + dropped at teardown. File: `..._cursor_test.go` / + `subagent_test.go`. +- **isBackground (Task 02):** completion `rawOutput.isBackground:true` sets + `IsAsync`; `false`/absent leaves it unset. File: `subagent_test.go`. +- **No-regression (Task 02/03):** existing subagent tests for Claude/OpenCode/ + Auggie still pass unchanged. + +--- + +## E2E Tests + +> No new Playwright spec. `cursor/task` requires a live Cursor login and cannot +> run in CI. Task 04 is a manual `acpdbg`/live-session verification against a +> real Cursor subagent, recording the rendered card (prompt, model, background). + +--- + +## Verification Results + +- `cd apps/backend && go test ./internal/agentctl/server/acp/... ./internal/agentctl/server/adapter/transport/acp/... ./internal/agentctl/server/utility/...` passed after adding a real wire-path test for inbound `cursor/task` handling. +- `cd apps && pnpm install --frozen-lockfile` passed in the worktree so focused frontend tests could run. +- `cd apps && pnpm --filter @kandev/web exec vitest run components/task/chat/messages/subagent-meta.test.ts components/task/chat/messages/tool-subagent-message.test.tsx` passed 41 tests across 2 files. +- No frontend code change was required: the existing subagent metadata chip/body renderers already display `model`, `is_async`, and `prompt` once the backend populates them. +- Manual live verification against a real Cursor-authenticated session is still pending (Task 04). + +--- + +## Implementation Waves And Parallel Candidates + +```text +Wave 1: +- [x] [task-01-sdk-extension-seam](task-01-sdk-extension-seam.md) + +Wave 2: +- [x] [task-02-parse-correlate-cursor-task](task-02-parse-correlate-cursor-task.md) + +Wave 3 (verification): +- [x] [task-03-frontend-confirm](task-03-frontend-confirm.md) (parallel-safe with Task 04) +- [ ] [task-04-manual-live-verify](task-04-manual-live-verify.md) +``` + +Sequential by default. Task 02 depends on Task 01's `CursorTaskHandler` seam. + +--- + +## Open Questions +- None. The one architectural question (Option A vs Option B) is resolved by + [ADR-2026-08-20-acp-client-non-underscore-extension-methods](../../decisions/2026-08-20-acp-client-non-underscore-extension-methods.md) + (Option A). Task 01 is unblocked. diff --git a/docs/plans/cursor-subagent-metadata/task-01-sdk-extension-seam.md b/docs/plans/cursor-subagent-metadata/task-01-sdk-extension-seam.md new file mode 100644 index 0000000000..d7515bf89d --- /dev/null +++ b/docs/plans/cursor-subagent-metadata/task-01-sdk-extension-seam.md @@ -0,0 +1,70 @@ +--- +id: "01-sdk-extension-seam" +title: "SDK extension seam for cursor/task" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/agents/cursor-subagent-metadata.md" +--- + +# Task 01: SDK extension seam for cursor/task + +Open a seam so the ACP client can accept the non-`_`-prefixed `cursor/task` +request instead of returning `-32601`, and expose the parsed request to the +adapter. + +Upstream ACP rejects non-`_` inbound methods on the client side. The +implementation that landed opens the seam in the pinned ACP fork instead: the +fork routes any unrecognized inbound client request to the client's +`ExtensionMethodHandler`, and Kandev passes its client to +`acp.NewClientSideConnection` with `WithCursorTaskHandler` (no repo-local +connection helper is involved). + +## Acceptance +- The pinned ACP fork routes inbound vendor request methods (including + `cursor/task`) to `Client.HandleExtensionMethod`; the outbound `_`-prefix + contract is unchanged (inbound-accept only). +- `apps/backend/internal/agentctl/server/acp/client.go` `Client` implements the + extension handler: `cursor/task` returns an empty success result; any other + unrecognized method returns `acp.NewMethodNotFound(method)`. +- A `WithCursorTaskHandler` client option (mirroring `WithUpdateHandler`) hands + the raw params to a callback the adapter can set. + +## Verification +`cd apps/backend && go test ./internal/agentctl/server/acp/...` + +## Files likely touched +- `apps/backend/internal/agentctl/server/acp/client.go` — add + `HandleExtensionMethod`, `WithCursorTaskHandler`, and the handler field. +- `apps/backend/internal/agentctl/server/acp/client_test.go` — dispatch tests. +- The pinned ACP fork (`kdlbs/acp-go-sdk` PR #3) — widen the client extension + seam; consumed via the `go.mod` `replace`. +- `apps/backend/internal/agentctl/server/acp/connection_test.go` (new) — live + wire-path test for `cursor/task` and unknown vendor requests. +- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go` and + `apps/backend/internal/agentctl/server/utility/acp_executor.go` — use the new + helper instead of constructing the ACP SDK connection directly. + +## Dependencies +None. ADR-2026-08-20 (Option A) is accepted; this task is unblocked. + +## Parallelism +sequential. + +## Inputs +- Spec: "API surface", "Failure modes". +- Plan: "Resolved decision and implemented seam", "Area 1". +- Code refs: `client.go` option pattern (`WithUpdateHandler`), ACP + `ClientSideConnection` wire framing, and the adapter/utility connection + call-sites. + +## Output contract +Summary, files changed, tests run, blockers, risks; update this task's status +and the `plan.md` Wave 1 checkbox. + +## Results +- Added `CursorTaskHandler`, `WithCursorTaskHandler`, `SetCursorTaskHandler`, + and `HandleExtensionMethod` to `apps/backend/internal/agentctl/server/acp/client.go` so `cursor/task` returns a success result and unknown vendor methods still return `-32601`. +- Widened the client extension seam in the pinned ACP fork (`kdlbs/acp-go-sdk` PR #3) so any unrecognized inbound client request routes to `HandleExtensionMethod`; consumed via the `go.mod` `replace`. No repo-local connection proxy was added; the adapter passes its client to `acp.NewClientSideConnection` with `WithCursorTaskHandler`. +- `cd apps/backend && go test ./internal/agentctl/server/acp/...` passed. diff --git a/docs/plans/cursor-subagent-metadata/task-02-parse-correlate-cursor-task.md b/docs/plans/cursor-subagent-metadata/task-02-parse-correlate-cursor-task.md new file mode 100644 index 0000000000..bc72fd8cc4 --- /dev/null +++ b/docs/plans/cursor-subagent-metadata/task-02-parse-correlate-cursor-task.md @@ -0,0 +1,77 @@ +--- +id: "02-parse-correlate-cursor-task" +title: "Parse and correlate cursor/task onto the subagent payload" +status: done +wave: 2 +depends_on: ["01-sdk-extension-seam"] +plan: "plan.md" +spec: "../../specs/agents/cursor-subagent-metadata.md" +--- + +# Task 02: Parse and correlate cursor/task onto the subagent payload + +Parse `cursor/task`, correlate it to the standard subagent `tool_call` by +`toolCallId`, and merge its metadata (`description`, `prompt`, `model`, +`agentId`) onto the existing `SubagentTaskPayload`. Also map the completion +`rawOutput.isBackground` onto `IsAsync`. + +## Acceptance +- A subagent `tool_call` plus a matching `cursor/task` (in either arrival order) + produces one `SubagentTaskPayload` carrying Cursor's description, prompt, + model, and agent id. +- An object-shaped `subagentType` is ignored (never coerced to the string + `SubagentType`); missing fields leave their targets zero-valued. +- An already-set field is not blanked by a later empty Cursor value. +- A `cursor/task` with no matching `tool_call` is dropped at session teardown. +- Completion `rawOutput.isBackground:true` sets `IsAsync`; `false`/absent does + not. + +## Verification +`cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp/...` + +## Files likely touched +- `apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor.go` (new) + — parse params, `applyCursorTaskMeta`. +- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_cursor.go` (new) + — store pending Cursor metadata, merge it in either arrival order, and clear + it on teardown. +- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go` — set + the `WithCursorTaskHandler` callback; per-session `map[toolCallId]cursorTaskMeta`; + clear on teardown. +- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_tools.go` — + merge pending metadata onto the first matching `tool_call` payload. +- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_session.go` + — clear unmatched pending metadata on session reset/teardown. +- `apps/backend/internal/agentctl/server/adapter/transport/acp/subagent.go` — + extend `cursorSubagentResult` to read `isBackground` → `IsAsync`. +- `apps/backend/internal/agentctl/server/adapter/transport/acp/dialect_cursor_test.go` (new) +- `apps/backend/internal/agentctl/server/adapter/transport/acp/subagent_test.go` — isBackground + correlation cases. + +## Dependencies +Task 01 (needs the `CursorTaskHandler` seam). + +## Parallelism +sequential. + +## Inputs +- Spec: "API surface" (exact param shape), "Failure modes", "Scenarios". +- Plan: "Area 2", "Area 3". +- Code refs: `subagent.go:224,349,371` (`extractSubagentResult`, + `cursorSubagentResult`, `applySubagentResult`); `tool_payload.go` + `SubagentTaskPayload`; `adapter.go:449` connection wiring. +- Wire fixture: `acp-debug/cursor-acp-linger-20260820-145213.jsonl`. + +## Output contract +Summary, files changed, tests run + counts, blockers, risks; update this task's +status and `plan.md` Wave 2 checkbox and Verification Results. + +## Results +- Added defensive Cursor param parsing for `toolCallId`, `agentId`, + `description`, `model`, and `prompt`, ignoring object-shaped + `subagentType`. +- Added per-session correlation state so `cursor/task` and the subagent + `tool_call` merge into one `SubagentTaskPayload` in either arrival order, and + unmatched pending state is dropped on teardown. +- Extended Cursor result enrichment so `rawOutput.isBackground:true` sets + `IsAsync`, which reuses the existing background-chip UI. +- `cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp/...` passed. diff --git a/docs/plans/cursor-subagent-metadata/task-03-frontend-confirm.md b/docs/plans/cursor-subagent-metadata/task-03-frontend-confirm.md new file mode 100644 index 0000000000..2639725642 --- /dev/null +++ b/docs/plans/cursor-subagent-metadata/task-03-frontend-confirm.md @@ -0,0 +1,61 @@ +--- +id: "03-frontend-confirm" +title: "Confirm frontend renders the Cursor subagent metadata" +status: done +wave: 3 +depends_on: ["02-parse-correlate-cursor-task"] +plan: "plan.md" +spec: "../../specs/agents/cursor-subagent-metadata.md" +--- + +# Task 03: Confirm frontend renders the Cursor subagent metadata + +Verify no frontend change is needed, or make the minimal one if a gap is found. +The `SubagentTaskPayload` TS type and the card/chip components already carry and +render `model`, `is_async` (background chip), and `prompt` (body fallback). + +## Acceptance +- With a populated Cursor `SubagentTaskPayload`, the subagent card shows the + model chip, the background chip (when `is_async`), and the prompt in the + expandable body (when no `result_text` and no children). +- If any expected field is present in the payload but not rendered, a minimal + targeted change is made and covered by a unit test; otherwise this task is a + confirmation with no code change. + +## Verification +`cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- components/task/chat/messages/subagent-meta.test.ts` +(and `cd apps/web && pnpm run typecheck` if any `.ts`/`.tsx` file changed) + +## Files likely touched +- (confirm only, likely none) `apps/web/components/task/chat/types.ts`, + `apps/web/components/task/chat/messages/subagent-meta.ts`, + `apps/web/components/task/chat/messages/tool-subagent-message.tsx`. +- `apps/web/components/task/chat/messages/subagent-meta.test.ts` — only if a + change is made. + +## Dependencies +Task 02 (the payload must actually be populated to verify). + +## Parallelism +parallel-safe with Task 04 (disjoint: frontend unit vs manual live run). + +## Inputs +- Spec: "What", "Scenarios". +- Plan: "Frontend". +- Code refs: `subagent-meta.ts:56,85`; `tool-subagent-message.tsx`; + `types.ts` `SubagentTaskPayload`. + +## Output contract +Summary (change vs confirm-only), files changed, tests run, blockers, risks; +update this task's status and `plan.md` Wave 3 checkbox. + +## Results +- Confirmed `apps/web/components/task/chat/types.ts` already includes the + populated fields (`prompt`, `model`, `agent_id`, `is_async`). +- Confirmed `apps/web/components/task/chat/messages/subagent-meta.ts` already + renders the model chip and background chip, and + `apps/web/components/task/chat/messages/tool-subagent-message.tsx` already + renders the prompt fallback body. +- No frontend code change was required. +- `cd apps && pnpm install --frozen-lockfile` passed. +- `cd apps && pnpm --filter @kandev/web exec vitest run components/task/chat/messages/subagent-meta.test.ts components/task/chat/messages/tool-subagent-message.test.tsx` passed 41 tests across 2 files. diff --git a/docs/plans/cursor-subagent-metadata/task-04-manual-live-verify.md b/docs/plans/cursor-subagent-metadata/task-04-manual-live-verify.md new file mode 100644 index 0000000000..7bd754cd0d --- /dev/null +++ b/docs/plans/cursor-subagent-metadata/task-04-manual-live-verify.md @@ -0,0 +1,52 @@ +--- +id: "04-manual-live-verify" +title: "Manual live verification against a real Cursor subagent" +status: pending +wave: 3 +depends_on: ["02-parse-correlate-cursor-task"] +plan: "plan.md" +spec: "../../specs/agents/cursor-subagent-metadata.md" +--- + +# Task 04: Manual live verification against a real Cursor subagent + +`cursor/task` needs a live Cursor login and cannot run in CI, so this is a +manual end-to-end check with `acpdbg` and/or a real Kandev session. + +## Acceptance +- `acpdbg` no longer records a `<<< SENT-reply ... err=method not found: + cursor/task` frame; instead a success reply is sent for `cursor/task`. +- In a live Kandev session using Cursor, launching a subagent renders a card + showing the subagent's description, prompt, and model; a background subagent + shows the background affordance. +- No regression for a Cursor non-subagent tool call, and no error surfaced to + the user. + +## Verification +Run the recorded reproduction and inspect frames: +`apps/backend/bin/acpdbg prompt --workdir /tmp/kandev-cursor-subagent --model "composer-2.5[fast=true]" --timeout 60s --file acp-debug/cursor-verify-$(date -u +%Y%m%d-%H%M%S).jsonl --prompt "Launch a subagent to summarize the JS files under src/." cursor-acp` +then confirm the `cursor/task` reply carries a result (not error) and the +metadata was captured. Also verify visually in a live Cursor session. + +## Files likely touched +- None (verification only). Records artifacts under `acp-debug/`. + +## Dependencies +Task 02 (behavior must be implemented). Task 01 (seam) transitively. + +## Parallelism +parallel-safe with Task 03. + +## Inputs +- Spec: "Scenarios", "Notes". +- Plan: "E2E Tests". +- Skill: `.claude/skills/acp-debug/SKILL.md`. +- Pinned: use `composer-2.5[fast=true]`; workspace `/tmp/kandev-cursor-subagent`. + +## Output contract +Summary, artifact paths (JSONL + any screenshots), pass/fail per acceptance +bullet, blockers, risks; update this task's status and `plan.md` Wave 3 checkbox +and Verification Results. Note the temporary capture files created. + +## Results +Pending. Not run in this workspace because it requires a real Cursor-authenticated live session and manual visual confirmation. diff --git a/docs/specs/agents/cursor-subagent-metadata.md b/docs/specs/agents/cursor-subagent-metadata.md new file mode 100644 index 0000000000..3c7b4b827a --- /dev/null +++ b/docs/specs/agents/cursor-subagent-metadata.md @@ -0,0 +1,148 @@ +--- +status: draft +created: 2026-08-20 +owner: cfl12 +--- + +# Cursor Subagent Metadata + +## Why + +When Cursor's agent dispatches a subagent, the only thing a Kandev user sees is a +generic `Task: Subagent task` card with no prompt, no model, and no identity. All +the useful detail (what the subagent was asked to do, which model runs it, its +agent id, whether it runs in the background) is delivered by Cursor over a +non-standard agent-to-client request, `cursor/task`, which Kandev currently +rejects. The subagent's work is therefore opaque, and a backgrounded subagent +looks identical to one that already finished. + +## What + +- A Cursor subagent card SHALL show the subagent's description, prompt, model, + and (for background subagents) a background indicator, sourced from the + `cursor/task` request Cursor emits alongside the standard `tool_call`. +- The rich metadata is correlated to the standard subagent `tool_call` by the + shared `toolCallId` Cursor puts in both, so the existing subagent card is + enriched in place rather than a second card appearing. +- `cursor/task` MUST NOT cause a JSON-RPC error back to Cursor. Kandev accepts + the request and returns an empty success result. +- A `cursor/task` that arrives with no matching subagent `tool_call` (or before + it) is retained and applied when the matching `tool_call` appears; a stored + correlation that is never matched is bounded and dropped when the session ends. +- Cursor reports background dispatch two ways that both map to the payload's + existing async concept: `cursor/task.model` ending in `-fast` is informational + only, while the completion `tool_call_update.rawOutput.isBackground == true` + marks the subagent as background. A background Cursor subagent renders the same + "background" affordance Claude's async subagents use. +- No behavior changes for any non-Cursor agent, and no change to how Cursor's + ordinary (non-subagent) tool calls render. + +## Data model + +No new persistent tables. The existing in-message +`streams.SubagentTaskPayload` +(`apps/backend/internal/agentctl/types/streams/tool_payload.go`) is the store; +its `Description`, `Prompt`, `SubagentType`, `Model`, `AgentID`, `DurationMs`, +and `IsAsync` fields already exist and already serialize to the frontend +`SubagentTaskPayload` (`apps/web/components/task/chat/types.ts`). This feature +populates the Cursor-shaped subset that is currently dropped; it adds no new +payload field unless correlation requires transient adapter-side state (a +per-session `map[toolCallId]cursorTask`, not persisted). + +## API surface + +`cursor/task` is an inbound agent-to-client JSON-RPC **request** (it carries an +`id`, so a response is required). Kandev is the ACP client. The method name does +not start with `_`, so the stock SDK extension hook +(`ExtensionMethodHandler`, only invoked for `_`-prefixed methods in +`kdlbs/acp-go-sdk` `extensions.go`) does not reach it, and the generated +`ClientSideConnection.handle` switch returns `-32601`. + +Observed request params (wire capture, `composer-2.5`): + +```text +cursor/task (request, has id) + agentId string e.g. "eaa12f70-...-60506ebd5c78" + description string e.g. "Summarize src JS files" + durationMs number launch duration, not the subagent's run time + model string e.g. "composer-2.5-fast" + prompt string the full subagent prompt + subagentType object e.g. {"custom":{"unspecified":{}}} — opaque, not a string + toolCallId string correlates to the standard tool_call, e.g. "tool_2494c23d-..." +``` + +Response: an empty JSON object result (no error). + +The correlated standard frames (unchanged path): the `tool_call` carries +`rawInput:{"_toolName":"task"}` and title `Task: Subagent task`; the completion +`tool_call_update` carries `rawOutput:{"durationMs":N,"isBackground":true|false}`. + +## Failure modes + +- **Unknown/extra fields in `cursor/task`:** parsed defensively over untyped + maps; missing fields stay zero-valued and the card degrades to whatever is + present. Never errors the request. +- **`subagentType` is an object, not a string:** it is not coerced into the + string `SubagentType` field; it is ignored unless a stable string label can be + derived. A malformed value never blocks the rest of the metadata. +- **Ordering (request before or after the `tool_call`):** correlation by + `toolCallId` is order-independent; whichever arrives second applies the merge. +- **No matching `tool_call` ever arrives:** the stored `cursor/task` is bounded + per session and discarded at session teardown; it never leaks across sessions. +- **A field already set by the standard frame is not blanked** by a later empty + Cursor value (mirrors `applySubagentResult`'s fill-if-present rule). + +## Persistence guarantees + +Captured metadata lives on the message payload and persists with the transcript +exactly as today. The transient `toolCallId -> cursorTask` correlation map is +in-memory adapter state and does NOT survive a restart; a subagent whose +`cursor/task` was seen only before a restart keeps whatever was already merged +onto its stored payload. + +## Scenarios + +- **GIVEN** Cursor emits a subagent `tool_call` and a matching `cursor/task` + request, **WHEN** both are processed, **THEN** the subagent card shows the + Cursor description, prompt, and model, and Kandev returns a success (not + `-32601`) for `cursor/task`. +- **GIVEN** a `cursor/task` arrives before its `tool_call`, **WHEN** the + `tool_call` later arrives, **THEN** the card is enriched with the earlier + metadata. +- **GIVEN** the completion `tool_call_update.rawOutput.isBackground` is `true`, + **WHEN** the card renders, **THEN** it shows the background affordance. +- **GIVEN** `isBackground` is `false` or absent, **WHEN** the card renders, + **THEN** no background affordance shows. +- **GIVEN** a `cursor/task` with a missing `model` and a present `prompt`, + **WHEN** the card renders, **THEN** the prompt shows and no empty model chip + appears. +- **GIVEN** any non-Cursor agent's subagent, **WHEN** it is processed, **THEN** + its rendering is byte-identical to before this feature. +- **GIVEN** a `cursor/task` that never matches a `tool_call`, **WHEN** the + session ends, **THEN** the stored correlation is dropped and no card appears. + +## Out of scope + +- Fetching or resuming a **parked background subagent's result** by `agentId`. + A wire capture proved Cursor never pushes the async result back over the ACP + session (125s linger, zero post-`end_turn` frames); retrieval would need a new + follow-up-prompt flow and is a separate feature. +- Result-text capture for Cursor. Unlike Claude, Cursor's completion frame + carries only `durationMs`/`isBackground`, no result content; this remains as + noted out of scope in + [subagent-observability](../ui/subagent-observability.md). +- Interpreting Cursor's `subagentType` object taxonomy. +- Any change to `active_subagent_count`, liveness, or the board chip + ([background-work-liveness](../platform/background-work-liveness.md)). + +## Notes + +- Wire evidence and the SDK dispatch analysis are recorded against task + `hey-a-user-reported` (`acpdbg --linger`, `composer-2.5`). The `cursor/task` + params and the `tool_call`/`tool_call_update` shapes above are from + `acp-debug/cursor-acp-linger-20260820-145213.jsonl`. +- Decision: [ADR-2026-08-20-acp-client-non-underscore-extension-methods](../../decisions/2026-08-20-acp-client-non-underscore-extension-methods.md) + resolves how `cursor/task` is accepted: the `kdlbs/acp-go-sdk` fork routes any + unrecognized inbound client method to the client's `ExtensionMethodHandler`, + and Kandev's `Client` returns success only for methods it recognizes + (`cursor/task`) and `NewMethodNotFound` otherwise.