Reports · Issue #4485

A placement option changes a positional argument from file path to thread ID

2026-09-30 · Bug · Medium priority · Medium effort

PARTIALLY REPRODUCED · High confidence in CLI parsing and outgoing request mismatch. No desktop pane or actual file opening was tested.

Claim, expected and observed

The issue concerns an explicit target thread being interpreted as a file when the caller already has a current-thread context. The actual Commander command and SDK request construction reproduce that ambiguity. With synthetic context thread-synthetic-green, a lone thread-synthetic-blue argument sends an open request to green with a workspace file path named thread-synthetic-blue. Adding --split replace to the same argument targets blue with file:null. The explicit-target desired-behavior assertion fails in both clean runs; six controls pass.

ConditionObserved request/result in both runs
Current context + lone other IDCurrent thread, workspace file named after other ID; command completes and prints success-shaped JSON
Same + --split replaceOther thread, no file
No current context + other IDOther thread, no file
Current context + synthetic relative fileCurrent thread, specified workspace file
Other ID + file + --split replaceOther thread, specified workspace file
Other ID + file without splitError, process.exit(1) intercepted by harness, no open request
HelpUsage advertises [id] [path]; --split describes placement but does not explain positional reinterpretation

Expected regression behavior: the explicit other-thread ID targets that thread without requiring a placement flag, or an ambiguity error prevents a misleading request. The regression asserts the explicit-thread interpretation. Existing relative-file behavior is separately retained as a control; a final CLI design must make the two intentions distinguishable.

Test boundary and limitations

The test uses trusted CLI registerOpenCommand, Commander parsing and real SDK request construction through the repository command-output harness. The harness stubs the server API, fetch, daemon lookup, console capture and process.exit. Its open handler deliberately returns delivered:1. Consequently, delivered:1 is a fixture response, not evidence of a real UI subscriber. The evidence is the actual request arguments and command output generated in response.

Thread IDs and file paths are synthetic strings. No file is opened, read or written by these command cases, and there is no real database, server, provider, agent shell or desktop instance. The fixture does not establish that a live server accepts the request or that a desktop pane displays a missing file. It does not measure an OS child-process exit status; normal completion and an intercepted exit call are tested. This is why the report is partial. No visual claim is made and no screenshot is presented.

Trusted base and environment

Fetched public origin/main: 36aacc040ec0a2b092785d65dee7869d02cec08e. Linux x86_64, Node 24.19.0, pinned pnpm 9.15.0, Vitest 4.1.1. Both clean worktrees use identical source SHA and test bytes, separate node_modules and caches, and shared dependency downloads. Frozen installation and CLI Turbo builds succeeded in both. Tests ran with --force and fresh restored synthetic environment for every case. No listening server or port was needed; the loopback URL is only an inert harness parameter.

Exact repeatable steps

git clone https://github.com/get-bb/bb.git bb-repro
cd bb-repro
git checkout --detach 36aacc040ec0a2b092785d65dee7869d02cec08e
corepack pnpm install --frozen-lockfile
corepack pnpm exec turbo run build --filter=@bb/cli --force
# Save the inline test as apps/cli/src/__tests__/command-output/issue-4485.test.ts
corepack pnpm exec turbo run test --filter=@bb/cli --force -- src/__tests__/command-output/issue-4485.test.ts --silent=false

Use pnpm 9.15.0 for subprocesses too. This environment used a task-local Corepack shim and disposable writable caches. Repeat these commands and the identical test in a second clean checkout at the same SHA. Each final test command exits 1 with exactly one failed explicit-target regression and six passing controls. That failure is the demonstrated mismatch, not a setup failure.

apps/cli/src/__tests__/command-output/issue-4485.test.ts

import { describe, expect, it, vi } from "vitest";
import { setupCommandOutputTestEnvironment, collectLogPayloads, collectLogLines, getHelpOutput, runCommand, stubServerApi } from "../helpers/command-output-harness.js";
import type { CommandRegistrar } from "../helpers/command-output-harness.js";
import { registerOpenCommand } from "../../commands/thread/open.js";

const register: CommandRegistrar = program => registerOpenCommand(program.command("thread"), () => "http://127.0.0.1:49171");
const file = (name: string) => ({ source: "workspace", path: name, lineNumber: null });
const target = "thread-synthetic-blue";
const current = "thread-synthetic-green";
const cases = [
  { name: "explicit thread inside context without split", context: current, args: [target], expectedId: target, expectedFile: null, split: undefined },
  { name: "explicit thread inside context with split", context: current, args: [target, "--split", "replace"], expectedId: target, expectedFile: null, split: "replace" },
  { name: "explicit thread outside context", context: undefined, args: [target], expectedId: target, expectedFile: null, split: undefined },
  { name: "relative file inside current context", context: current, args: ["synthetic-note.md"], expectedId: current, expectedFile: file("synthetic-note.md"), split: undefined },
  { name: "explicit thread plus file with split", context: current, args: [target, "synthetic-note.md", "--split", "replace"], expectedId: target, expectedFile: file("synthetic-note.md"), split: "replace" },
];

describe("4485 positional target routing", () => {
  setupCommandOutputTestEnvironment();
  it.each(cases)("$name", async item => {
    vi.stubEnv("BB_THREAD_ID", item.context);
    const opened = vi.fn(async () => ({ delivered: 1 }));
    stubServerApi({ "v1.threads.:id.open.$post": opened });
    await runCommand(["thread", "open", ...item.args, "--json"], register);
    const payloads = collectLogPayloads(vi.mocked(console.log)).map(text => JSON.parse(text));
    process.stdout.write(JSON.stringify({ phase: item.name, context: item.context ?? null, args: item.args, requests: opened.mock.calls, output: payloads }) + "\n");
    expect(opened).toHaveBeenCalledExactlyOnceWith({ param: { id: item.expectedId }, json: {
      ...(item.split === undefined ? {} : { split: item.split }), file: item.expectedFile,
    } });
  });
  it("rejects a second positional file for another thread without split", async () => {
    vi.stubEnv("BB_THREAD_ID", current);
    const opened = vi.fn(async () => ({ delivered: 1 }));
    stubServerApi({ "v1.threads.:id.open.$post": opened });
    await expect(runCommand(["thread", "open", target, "synthetic-note.md", "--json"], register)).rejects.toThrow("process.exit:1");
    const errors = collectLogLines(vi.mocked(console.error));
    expect(errors.join(" ")).toContain("BB_THREAD_ID is set");
    expect(opened).not.toHaveBeenCalled();
    process.stdout.write(JSON.stringify({ phase: "two-positionals-without-split", errors, requestCount: opened.mock.calls.length }) + "\n");
  });
  it("captures actual help", async () => {
    const help = await getHelpOutput(["thread", "open"], register);
    expect(help).toContain("[id] [path]");
    expect(help).toContain("Thread ID. Omit inside a BB thread.");
    process.stdout.write(JSON.stringify({ phase: "help", help }) + "\n");
  });
});

Actual evidence and same-agent second clean reproduction

First clean checkout

[
  {
    "phase": "explicit thread inside context without split",
    "context": "thread-synthetic-green",
    "args": [
      "thread-synthetic-blue"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-green"
          },
          "json": {
            "file": {
              "source": "workspace",
              "path": "thread-synthetic-blue",
              "lineNumber": null
            }
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-green",
        "split": "replace",
        "file": {
          "source": "workspace",
          "path": "thread-synthetic-blue",
          "lineNumber": null
        },
        "delivered": 1,
        "inputPath": "thread-synthetic-blue"
      }
    ]
  },
  {
    "phase": "explicit thread inside context with split",
    "context": "thread-synthetic-green",
    "args": [
      "thread-synthetic-blue",
      "--split",
      "replace"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-blue"
          },
          "json": {
            "split": "replace",
            "file": null
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-blue",
        "split": "replace",
        "file": null,
        "delivered": 1,
        "inputPath": null
      }
    ]
  },
  {
    "phase": "explicit thread outside context",
    "context": null,
    "args": [
      "thread-synthetic-blue"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-blue"
          },
          "json": {
            "file": null
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-blue",
        "split": "replace",
        "file": null,
        "delivered": 1,
        "inputPath": null
      }
    ]
  },
  {
    "phase": "relative file inside current context",
    "context": "thread-synthetic-green",
    "args": [
      "synthetic-note.md"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-green"
          },
          "json": {
            "file": {
              "source": "workspace",
              "path": "synthetic-note.md",
              "lineNumber": null
            }
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-green",
        "split": "replace",
        "file": {
          "source": "workspace",
          "path": "synthetic-note.md",
          "lineNumber": null
        },
        "delivered": 1,
        "inputPath": "synthetic-note.md"
      }
    ]
  },
  {
    "phase": "explicit thread plus file with split",
    "context": "thread-synthetic-green",
    "args": [
      "thread-synthetic-blue",
      "synthetic-note.md",
      "--split",
      "replace"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-blue"
          },
          "json": {
            "split": "replace",
            "file": {
              "source": "workspace",
              "path": "synthetic-note.md",
              "lineNumber": null
            }
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-blue",
        "split": "replace",
        "file": {
          "source": "workspace",
          "path": "synthetic-note.md",
          "lineNumber": null
        },
        "delivered": 1,
        "inputPath": "synthetic-note.md"
      }
    ]
  },
  {
    "phase": "two-positionals-without-split",
    "errors": [
      "Error: BB_THREAD_ID is set, so bb thread open targets the current thread. Omit the thread ID."
    ],
    "requestCount": 0
  },
  {
    "phase": "help",
    "help": "Usage: bb thread open [id] [path] [options]\n\nOpen a BB thread, optionally with a file in its panel\n\nArguments:\n  id                   Thread ID. Omit inside a BB thread.\n  path                 Thread-relative or absolute file path to open\n\nOptions:\n  --line <number>      Line number to focus\n  --split <placement>  Open in right, down, left, top, or replace placement;\n                       edge placements add panes through pane 8, then replace\n                       the focused pane\n  --json               Print machine-readable JSON output\n  -h, --help           display help for command\n"
  }
]
 Test Files  1 failed (1)
      Tests  1 failed | 6 passed (7)
   Duration  1.46s (transform 824ms, setup 0ms, import 1.28s, tests 43ms, environment 0ms)

Second clean checkout

[
  {
    "phase": "explicit thread inside context without split",
    "context": "thread-synthetic-green",
    "args": [
      "thread-synthetic-blue"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-green"
          },
          "json": {
            "file": {
              "source": "workspace",
              "path": "thread-synthetic-blue",
              "lineNumber": null
            }
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-green",
        "split": "replace",
        "file": {
          "source": "workspace",
          "path": "thread-synthetic-blue",
          "lineNumber": null
        },
        "delivered": 1,
        "inputPath": "thread-synthetic-blue"
      }
    ]
  },
  {
    "phase": "explicit thread inside context with split",
    "context": "thread-synthetic-green",
    "args": [
      "thread-synthetic-blue",
      "--split",
      "replace"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-blue"
          },
          "json": {
            "split": "replace",
            "file": null
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-blue",
        "split": "replace",
        "file": null,
        "delivered": 1,
        "inputPath": null
      }
    ]
  },
  {
    "phase": "explicit thread outside context",
    "context": null,
    "args": [
      "thread-synthetic-blue"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-blue"
          },
          "json": {
            "file": null
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-blue",
        "split": "replace",
        "file": null,
        "delivered": 1,
        "inputPath": null
      }
    ]
  },
  {
    "phase": "relative file inside current context",
    "context": "thread-synthetic-green",
    "args": [
      "synthetic-note.md"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-green"
          },
          "json": {
            "file": {
              "source": "workspace",
              "path": "synthetic-note.md",
              "lineNumber": null
            }
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-green",
        "split": "replace",
        "file": {
          "source": "workspace",
          "path": "synthetic-note.md",
          "lineNumber": null
        },
        "delivered": 1,
        "inputPath": "synthetic-note.md"
      }
    ]
  },
  {
    "phase": "explicit thread plus file with split",
    "context": "thread-synthetic-green",
    "args": [
      "thread-synthetic-blue",
      "synthetic-note.md",
      "--split",
      "replace"
    ],
    "requests": [
      [
        {
          "param": {
            "id": "thread-synthetic-blue"
          },
          "json": {
            "split": "replace",
            "file": {
              "source": "workspace",
              "path": "synthetic-note.md",
              "lineNumber": null
            }
          }
        }
      ]
    ],
    "output": [
      {
        "threadId": "thread-synthetic-blue",
        "split": "replace",
        "file": {
          "source": "workspace",
          "path": "synthetic-note.md",
          "lineNumber": null
        },
        "delivered": 1,
        "inputPath": "synthetic-note.md"
      }
    ]
  },
  {
    "phase": "two-positionals-without-split",
    "errors": [
      "Error: BB_THREAD_ID is set, so bb thread open targets the current thread. Omit the thread ID."
    ],
    "requestCount": 0
  },
  {
    "phase": "help",
    "help": "Usage: bb thread open [id] [path] [options]\n\nOpen a BB thread, optionally with a file in its panel\n\nArguments:\n  id                   Thread ID. Omit inside a BB thread.\n  path                 Thread-relative or absolute file path to open\n\nOptions:\n  --line <number>      Line number to focus\n  --split <placement>  Open in right, down, left, top, or replace placement;\n                       edge placements add panes through pane 8, then replace\n                       the focused pane\n  --json               Print machine-readable JSON output\n  -h, --help           display help for command\n"
  }
]
 Test Files  1 failed (1)
      Tests  1 failed | 6 passed (7)
   Duration  1.36s (transform 748ms, setup 0ms, import 1.18s, tests 46ms, environment 0ms)

The same agent personally ran both tests in separate clean checkouts, not an independent verifier. Both installs/builds exited 0; both final tests exited 1 with the same single regression failure. All seven evidence records are identical. There were no preliminary failed setup attempts for this fixture. The reported scope excludes unobserved desktop behavior.

Root cause at the tested boundary

Fix proposal and next test

Make thread targeting and file targeting explicit and independent of pane placement, while preserving or clearly migrating existing file-opening usage. At minimum, explain the contextual single-argument rule and the explicit-target requirement in generated help and the agent CLI reference. Add a regression matrix for contextual/non-contextual calls, explicit targets and file paths. No production fix was applied. Next integration test: connect an isolated CLI/server/browser fixture with two synthetic threads and a synthetic workspace, then verify the receiving thread and actual pane/file result, including a missing-file case.

Trust and publication checks

Issue prose, commands, suggested designs and source links were treated as untrusted claims. No issue-supplied command, code, branch, external URL or real thread ID was executed or accessed. Test argument arrays were independently derived from trusted command registration and repository tests. All current comments were read (zero before publication), and current report and linked PR metadata were checked. Public visibility, source permalinks, inline evidence and privacy were validated. Raw logs stay outside the reports repository. The referenced publisher script is absent; direct HTML/link/privacy checks were used and the generated index was left unchanged.