← reports

#1833 · acp-cursor advertises fork support it does not have; forking births an errored thread

Bug Priority: Medium Effort: unset providers provider-acp open on GitHub 2026-08-20 · base c7c66423d

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

A Cursor (acp-cursor) thread shows a "Fork into new thread" button, and bb thread fork accepts the request, but the forked thread immediately goes to status error with Command thread.start failed — ACP agent "cursor-agent acp" does not advertise session/fork support. The cause is a static capability declaration that disagrees with the agent: plugins/provider-acp/server.ts registers acp-cursor with fork: "tip", which the server projects to supportsFork: true for the API/app and for its own POST /threads/fork gate. The actual ACP initialize reply from cursor-agent (2026.08.11) advertises sessionCapabilities: { list: {} } and no fork, and the ACP bridge correctly refuses to send session/fork in that case — but that check happens only after the fork thread has already been created and started. The same tier-wide supportsFork: true fallback also applies to every known/custom ACP agent (e.g. acp-grok, which also advertises no fork), so the problem is broader than Cursor. Setting the Cursor declaration to fork: "none" makes the server reject the fork up front (HTTP 400) and hides the button; verified live.

2. Claims vs findings

Claim from the issueStatusEvidence
The provider roster advertises supportsFork: true for acp-cursorVerifiedGET /api/v1/system/providers on the dev instance returns supportsFork: true for acp-cursor (providers-supportsFork.txt). Source: plugins/provider-acp/server.ts#L16-L26 declares fork: "tip", projected by plugin-provider-registration.ts#L84-L85.
cursor-agent does not advertise ACP session/forkVerifiedSent the exact initialize request bb's bridge sends to cursor-agent acp (2026.08.11-e8db854): agentCapabilities.sessionCapabilities = { list: {} }, no fork (cursor-initialize-result.txt).
bb thread fork on a cursor thread births an errored thread with system/error: "does not advertise session/fork support"VerifiedLive repro below: fork returned 201 / thread thr_wgnhm8hhpy with status starting, then status: error and event seq 3 system/error {code: thread_command_failed, detail: 'ACP agent "cursor-agent acp" does not advertise session/fork support.'}.
The guard itself is correct; the roster capability is wrongVerifiedGuard at bridge.ts#L1724-L1730 reads the agent's real initialize answer. The declaration is the only thing saying "yes".
ACP fork works where the agent supports it (acp-opencode)Unverifiedopencode is not installed on this machine. Not needed for the bug; the bridge code path for a fork-capable agent was not exercised here.
Pre-existing on origin/main, not a cutover regressionVerifiedReproduced at base c7c66423d; fork: "tip" for acp-cursor was introduced in c5b53caab (#1640). No fix on origin/main as of 5f4172be3 (git log c7c66423d..origin/main -- plugins/provider-acp apps/server/src/services/providers is empty).
Fix direction: project fork per-agent from sessionCapabilities.fork at initialize timePartlySound in principle, but the ACP agent initialize happens per session inside the bridge, after the bridge handshake, and the server's supportsFork/ProviderInfo are static declarations with no daemon→server capability channel. See §6 for a cheaper correct fix and the larger one.

3. Environment

4. Minimal reproduction

A. Prove the agent does not advertise fork (no bb needed)

  1. Run probe-cursor-acp-initialize.mjs, which spawns cursor-agent acp and sends the same initialize request as bb's bridge:
    $ node probe-cursor-acp-initialize.mjs
    {
      "protocolVersion": 1,
      "agentCapabilities": {
        "loadSession": true,
        "mcpCapabilities": {
          "http": true,
          "sse": true
        },
        "promptCapabilities": {
          "audio": false,
          "embeddedContext": false,
          "image": true
        },
        "sessionCapabilities": {
          "list": {}
        }
      },
      "authMethods": [
        {
          "id": "cursor_login",
          "name": "Cursor Login",
          "description": "Authenticate using existing Cursor login credentials. Run 'agent login' first if not logged in."
        }
      ]
    }
    sessionCapabilities.fork advertised: false
    
    Expected (for the roster to be right): sessionCapabilities.fork present. Actual: only list. For contrast, grok agent stdio (provider acp-grok, also advertised with supportsFork: true) replies sessionCapabilities: { list, resume, close } — no fork either (grok-initialize-result.txt).

B. Live repro through the CLI

  1. Start a dev instance (scripts/bb-dev-app current), eval "$(scripts/bb-dev-app env)", create a project on a scratch git repo (see brief), then spawn a Cursor thread and wait for it:
    $ pnpm bb:dev thread spawn --project proj_thd6gcvyyy --provider acp-cursor --machine host_ychzpg73cy \
        --environment /tmp/bb-1833-qa-repo --prompt "Reply only with ok." --json
    { "id": "thr_7wvkgmjn97", "providerId": "acp-cursor", "status": "starting", ... }
    $ pnpm bb:dev thread wait thr_7wvkgmjn97 --status idle --timeout 180
    Thread thr_7wvkgmjn97 reached status idle.
  2. Check what the server advertises:
    $ curl -s $BB_SERVER_URL/api/v1/system/providers | python3 -c 'import json,sys; [print(p["id"], p["capabilities"]["supportsFork"]) for p in json.load(sys.stdin)]'
    codex True
    claude-code True
    pi True
    acp-cursor True      <-- wrong
    acp-grok True        <-- also wrong (grok advertises no session/fork)
  3. Fork the thread:
    $ pnpm bb:dev thread fork thr_7wvkgmjn97 --workspace reuse --prompt "Reply only with ok." --json
    { "id": "thr_wgnhm8hhpy", "sourceThreadId": "thr_7wvkgmjn97", "originKind": "fork", "status": "starting", ... }
    
    expected: HTTP 400 "Provider acp-cursor does not support thread forks" (no thread created),
              or a working forked session.
    actual:   201, a new thread is created, then ~1s later:
    
    $ pnpm bb:dev thread show thr_wgnhm8hhpy
    Thread: thr_wgnhm8hhpy
      Status: error
    $ pnpm bb:dev thread log thr_wgnhm8hhpy --json     # event seq 3:
    {
      "type": "system/error",
      "data": {
        "code": "thread_command_failed",
        "message": "Command thread.start failed",
        "detail": "ACP agent \"cursor-agent acp\" does not advertise session/fork support."
      }
    }
    Raw outputs: step1-spawn.txt, step2-fork.txt, step3-fork-status.txt, step3-fork-log.json.
Cursor source thread with Fork into new thread tooltip
Source acp-cursor thread in the app: hovering the user message reveals the message action bar and the "Fork into new thread" action (tooltip shown). The app offers it because ProviderInfo.capabilities.supportsFork is true.
Forked thread in error state
The forked thread (thr_wgnhm8hhpy, "Forked from Reply only with ok.") immediately shows Command thread.start failed — ACP agent "cursor-agent acp" does not advertise session/fork support. and the composer says "Retry by sending a follow-up message".

C. Unit-level repro (fails on base, passes with the fix)

apps/server/test/services/plugins/issue-1833-acp-cursor-fork.test.ts (uses the real plugin runtime and in-memory SQLite via the existing withTestHarness):

import { describe, expect, it } from "vitest";
import { listSystemProviderInfos } from "../../../src/services/system/execution-options.js";
import { withTestHarness } from "../../helpers/test-app.js";

/**
 * Repro for get-bb/bb#1833.
 *
 * cursor-agent's ACP `initialize` result (cursor-agent 2026.08.11) advertises
 * `sessionCapabilities: { list: {} }` and no `fork`, so the ACP bridge refuses
 * every `thread/fork` for it ("does not advertise session/fork support").
 * The server-side declaration in plugins/provider-acp/server.ts nevertheless
 * says `fork: "tip"`, so the registry, `POST /threads/fork` and the app all
 * offer fork for acp-cursor, and every fork births an errored thread.
 *
 * On c7c66423d the two assertions below FAIL (supportsFork is `true`).
 * They pass once the acp-cursor declaration says `fork: "none"` (or the
 * capability is otherwise derived from what the agent actually advertises).
 */
describe("issue #1833: acp-cursor fork capability", () => {
  it("does not advertise fork for acp-cursor", async () => {
    await withTestHarness(
      { seedFirstPartyProviders: false },
      async (harness) => {
        const entry = await harness.pluginService.install(
          "builtin:provider-acp",
          { kind: "root" },
        );
        expect(entry.status, entry.statusDetail ?? "").toBe("running");
        const registry = harness.deps.providerRegistry;

        // Server-side gate used by POST /api/v1/threads/fork.
        expect(registry.supportsFork("acp-cursor")).toBe(false);

        // Client-facing ProviderInfo used by the app's fork affordance.
        const infos = await listSystemProviderInfos(harness.deps, {});
        const cursor = infos.find((info) => info.id === "acp-cursor");
        expect(cursor?.capabilities.supportsFork).toBe(false);
      },
    );
  }, 60_000);
});
$ cd apps/server && pnpm exec vitest run test/services/plugins/issue-1833-acp-cursor-fork.test.ts   # on c7c66423d

 RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_926b3193-f6c-10/apps/server

 ❯  @bb/server  test/services/plugins/issue-1833-acp-cursor-fork.test.ts (1 test | 1 failed) 375ms
     × does not advertise fork for acp-cursor 374ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL   @bb/server  test/services/plugins/issue-1833-acp-cursor-fork.test.ts > issue #1833: acp-cursor fork capability > does not advertise fork for acp-cursor
AssertionError: expected true to be false // Object.is equality

- Expected
+ Received

- false
+ true

 ❯ test/services/plugins/issue-1833-acp-cursor-fork.test.ts:32:53
     30|
     31|         // Server-side gate used by POST /api/v1/threads/fork.
     32|         expect(registry.supportsFork("acp-cursor")).toBe(false);
       |                                                     ^
     33|
     34|         // Client-facing ProviderInfo used by the app's fork affordanc…
 ❯ withTestHarness test/helpers/test-app.ts:289:12
 ❯ test/services/plugins/issue-1833-acp-cursor-fork.test.ts:21:5

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  14:40:54
   Duration  3.02s (transform 1.47s, setup 0ms, import 2.55s, tests 375ms, environment 0ms)

With the one-line fix applied the same test passes (vitest-with-fix.txt). The assertion that fails is registry.supportsFork("acp-cursor") === false: the server's own fork gate says yes, because the declaration says "tip".

Repro files: 1833/repro/

5. Root cause

Two sources of truth disagree and the wrong one is consulted first.

  1. Static declaration says yes. plugins/provider-acp/server.ts#L16-L26 registers acp-cursor with fork: "tip". plugin-provider-registration.ts#L79-L85 projects that to supportsFork: capabilities.fork !== "none"true in the client-facing ProviderInfo, and keeps fork: "tip" in serverCapabilities. The registry's supportsFork() returns that boolean. The app reads it in useForkThreadFromMessage.ts#L50-L56 / ThreadDetailView.tsx#L993 and shows the fork action.
  2. Server gate trusts the declaration and creates the thread. thread-fork.ts#L35-L46 (requireForkCapableProvider) passes, so createThreadForkFromRequest inserts a new thread with originKind: "fork" and dispatches thread/start with a fork request to the host daemon.
  3. Runtime/adapter also trusts it. In the daemon, bridge-protocol-adapter.ts#L208-L227 computes the effective fork ladder as min(declaredFork, handshake.fork); the ACP bridge's handshake answers fork: "tip" unconditionally (bridge.ts#L2357-L2383, comment: "each agent's support is verified per session at agent initialize"), so the thread/fork gate at #L333-L350 lets the request through.
  4. The only honest check is last. The bridge spawns cursor-agent acp, sends ACP initialize, and at bridge.ts#L1724-L1730 throws does not advertise session/fork support because agentCapabilities.sessionCapabilities.fork is absent. That error propagates as the thread's system/error {code: thread_command_failed} and the already-created thread lands in error.

Deeper issue. The whole ACP tier declares fork unconditionally: ACP_TIER_CAPABILITIES.supportsFork = true / ACP_FORK = "tip" is the registry's fallback for every known agent (acp-grok, acp-omp, acp-hermes-agent, acp-opencode) and every custom acp-* agent. Grok, verified above, advertises no session/fork either, so acp-grok has the identical failure. Per-agent variation already has a precedent in this code base: supportsManualCompaction is declared per agent in known-acp-agents.ts and in customAcpAgents config, resolved through resolveAcpAgentCapabilitiesForProviderId. Fork simply was not given the same treatment.

Why the issue's "derive it from the agent at initialize" is not a drop-in: the ACP initialize is sent per session, inside the bridge, after the bridge↔runtime handshake and after the server has already created the thread; and the server's supportsFork / ProviderInfo are static and have no daemon→server capability channel. Doing it dynamically would mean spawning the agent once to probe capabilities and plumbing the result up through the host daemon protocol (and a HOST_DAEMON_PROTOCOL_VERSION bump).

6. Proposed fix (first principles)

Minimal, verified: change fork: "tip"fork: "none" in plugins/provider-acp/server.ts#L19 (diff). With this applied and the dev instance restarted: GET /system/providers reports acp-cursor supportsFork: false; bb thread fork thr_7wvkgmjn97 ... returns HTTP 400: Provider acp-cursor does not support thread forks and creates no thread (step4-fork-after-fix.txt); the "Fork into new thread" action no longer renders on the cursor thread (the screenshot script's waitForSelector('button[aria-label="Fork into new thread"]') times out); and the repro test passes. The adapter's declared ceiling also becomes "none", so even a direct thread/fork to the runtime is rejected before the agent is spawned. Risk: if a future cursor-agent adds session/fork, fork stays off until the declaration is flipped back — acceptable, it is a declaration just like supportsManualCompaction.

Complete fix (same change, applied to the tier): add a per-agent fork: ProviderFork (or supportsFork: boolean) to KnownAcpAgent and to the customAcpAgents config schema (default "none", mirroring supportsManualCompaction), return it from resolveAcpAgentCapabilitiesForProviderId, make providerRegistry.supportsFork/getServerCapabilities for dynamic acp-* ids read it instead of ACP_TIER_CAPABILITIES, and have buildAcpProviderInfo take fork from the agent record so the API/app agree. Set acp-opencode to "tip" (the issue says it works there) and the rest to "none" until verified. This touches only the server (product policy per AGENTS.md) and changes no wire shape between server and daemon, so no protocol bump is needed. Update docs/configuration.md for the new custom-agent field.

Optional hardening: a server-side preflight in thread-fork.ts cannot see the agent; leave the bridge guard in place as the last line of defense.

7. PR review

No open pull requests are linked to this issue.

8. Related issues

9. Appendix

Grok ACP initialize (excerpt, for the tier-wide point)

{
  "protocolVersion": 1,
  "agentCapabilities": {
    "loadSession": true,
    "promptCapabilities": {
      "image": false,
      "audio": false,
      "embeddedContext": true
    },
    "mcpCapabilities": {
      "http": true,
      "sse": true
    },
    "sessionCapabilities": {
      "list": {},
      "resume": {},
      "close": {}
    }
    ...

Commands run (condensed)

pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
node /tmp/bb-reports/issues/1833/repro/probe-cursor-acp-initialize.mjs            # cursor-agent acp
node /tmp/bb-reports/issues/1833/repro/probe-cursor-acp-initialize.mjs grok agent stdio
scripts/bb-dev-app current ; scripts/bb-dev-app env
git -C /tmp/bb-1833-qa-repo init ... commit
curl -s -X POST $BB_SERVER_URL/api/v1/projects -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/bb-1833-qa-repo","hostId":"host_ychzpg73cy"}}'
pnpm bb:dev thread spawn --project proj_thd6gcvyyy --provider acp-cursor --machine host_ychzpg73cy --environment /tmp/bb-1833-qa-repo --prompt "Reply only with ok." --json
pnpm bb:dev thread wait thr_7wvkgmjn97 --status idle --timeout 180
pnpm bb:dev thread fork thr_7wvkgmjn97 --workspace reuse --prompt "Reply only with ok." --json
pnpm bb:dev thread show thr_wgnhm8hhpy ; pnpm bb:dev thread log thr_wgnhm8hhpy --json
doobie --headless run /tmp/bb-reports/issues/1833/repro/screenshot.js ; ... screenshot-source.js
sed -i 's/fork: "tip"/fork: "none"/' plugins/provider-acp/server.ts ; scripts/bb-dev-app stop ; scripts/bb-dev-app current
curl -s $BB_SERVER_URL/api/v1/system/providers | ...   # acp-cursor False
pnpm bb:dev thread fork thr_7wvkgmjn97 --workspace reuse --prompt "Reply only with ok." --json   # HTTP 400
cd apps/server && pnpm exec vitest run test/services/plugins/issue-1833-acp-cursor-fork.test.ts   # pass with fix, fail on base
git checkout plugins/provider-acp/server.ts
git fetch origin main ; git log c7c66423d..origin/main --oneline -- plugins/provider-acp apps/server/src/services/providers apps/server/src/services/threads/thread-fork.ts   # empty
pnpm dev:stop ; rm -rf ~/.bb-dev/projects-bb-.claude-worktrees-wf_926b3193-f6c-10-ad7768632be1 /tmp/bb-1833-qa-repo

Full fork-thread event log

step3-fork-log.json — seq 1 client/turn/requested, seq 2 client/thread/start, seq 3 system/error (quoted above).