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.
| Condition | Observed request/result in both runs |
|---|---|
| Current context + lone other ID | Current thread, workspace file named after other ID; command completes and prints success-shaped JSON |
| Same + --split replace | Other thread, no file |
| No current context + other ID | Other thread, no file |
| Current context + synthetic relative file | Current thread, specified workspace file |
| Other ID + file + --split replace | Other thread, specified workspace file |
| Other ID + file without split | Error, process.exit(1) intercepted by harness, no open request |
| Help | Usage 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
- Command target selection uses the presence of opts.split to set allowsExplicitThreadTarget before resolving the default replacement placement.
- Context-dependent single-argument branch interprets the lone argument as an explicit thread when that boolean is true; otherwise the return below treats it as inputPath on the context thread.
- SDK open request sends that resolved thread/file combination. Relative file resolution normalizes a relative path without a filesystem existence check; that is a source observation, not a test of a missing file in a live workspace.
- Current help declaration documents thread/path arguments and placement but not the overloaded positional rule. The report contains the actual generated help output.
- Existing command-output harness provides the isolated API boundary used here; Existing explicit-split test already treats that branch as intentional behavior. This report demonstrates the ambiguity; it does not characterize the rule as an accidental implementation typo.
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.