#3375 · Fork eligibility omits archive state
Verdict: REPRODUCED · Root-cause confidence: high · Reproduction label: confirmed-repro
1. TL;DR
The client considers an archived source eligible for forking whenever it has an environment and its provider supports forks. The same helper controls both the timeline fork action and the navigation into the fork composer. The server rejects an archived source during thread creation. A focused regression demonstrates the wrong eligibility result in two clean trusted-base runs. This is a functional eligibility reproduction; the complete desktop/BB Connect interaction and rendered button were not replayed.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| Archived sources remain eligible for the fork action | Verified at the shared eligibility function | Expected false; received true in both runs. The view and hook use this function. |
| Creation rejects an archived source | Verified by source inspection; HTTP request not replayed | requireLiveSourceThread throws a 400 invalid_request for archivedAt !== null. |
| Occurs in installed desktop 0.42.1 through Connect | Unverified platform detail | No installed desktop or Connect session used. |
3. Environment
Trusted origin/main commit cb74e892fd73ac95410717db9509d9b0f7a54d8d; public repository get-bb/bb. macOS Darwin 25.6.0 arm64; Node 22.22.3; repository-pinned pnpm 9.15.0 via Corepack; Vitest 4.1.1. Frozen install and the full Turbo build completed (20 tasks). No application instance, provider, database, ports, credentials, or runtime data were needed. The system pnpm launcher initially pointed to a missing module; a temporary Corepack shim selected the pinned version.
4. Minimal reproduction
Use a clean checkout at the pinned commit, save the complete test file below over packages/client-core/test/fork-thread-request.test.ts, and run:
git checkout cb74e892fd73ac95410717db9509d9b0f7a54d8d pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run test --filter=@bb/client-core -- --run test/fork-thread-request.test.ts
The added test constructs a normal thread with an environment and archivedAt=123, then asks whether a fork-capable provider makes it eligible. Expected and actual:
Expected: isThreadForkable(archivedSource, true) === false Actual: true AssertionError: expected true to be false Test Files 1 failed (1) Tests 1 failed | 5 passed (6)
The two executions produced the same archive eligibility failure. No screenshot is claimed: this reproduces action eligibility rather than a visual rendering defect.
Complete repeatable test file
import type { Thread } from "@bb/domain";
import { makeThread as makeThreadFixture } from "@bb/test-helpers/domain-fixtures";
import { describe, expect, it } from "vitest";
import {
buildForkThreadRequest,
isThreadForkable,
} from "../src/prompt/fork-thread-request.js";
function makeThread(overrides: Partial<Thread> = {}): Thread {
return makeThreadFixture({
createdAt: 1,
environmentId: "env_source",
id: "thr_source",
lastReadAt: null,
latestAttentionAt: 1,
title: "Investigate flaky test",
titleFallback: null,
updatedAt: 1,
...overrides,
});
}
describe("buildForkThreadRequest", () => {
it("reuses the source environment and starts with the user's first message", () => {
const request = buildForkThreadRequest({
environmentId: "env_source",
input: [{ type: "text", text: "Continue from here", mentions: [] }],
model: "gpt-5",
permissionMode: "accept-edits",
projectId: "proj_test",
providerId: "codex",
providerSupportsFork: true,
reasoningLevel: "high",
serviceTier: "fast",
sourceSeqEnd: 42,
sourceThreadId: "thr_source",
sourceThreadTitle: "Investigate flaky test",
});
expect(request).toEqual({
environment: { type: "reuse", environmentId: "env_source" },
input: [{ type: "text", text: "Continue from here", mentions: [] }],
model: "gpt-5",
originKind: "fork",
permissionMode: "accept-edits",
projectId: "proj_test",
providerId: "codex",
reasoningLevel: "high",
serviceTier: "fast",
sourceSeqEnd: 42,
sourceThreadId: "thr_source",
startedOnBehalfOf: null,
});
});
it("omits unsupported service tier", () => {
const request = buildForkThreadRequest({
environmentId: "env_source",
input: [{ type: "text", text: "Continue from here", mentions: [] }],
model: "gpt-5",
permissionMode: "auto",
projectId: "proj_test",
providerId: "codex",
providerSupportsFork: true,
reasoningLevel: "medium",
serviceTier: undefined,
sourceSeqEnd: undefined,
sourceThreadId: "thr_source",
sourceThreadTitle: "Investigate flaky test",
});
expect(request).not.toHaveProperty("serviceTier");
});
it("builds a fork request for a generic ACP provider", () => {
expect(
buildForkThreadRequest({
environmentId: "env_source",
input: [{ type: "text", text: "Continue from here", mentions: [] }],
model: "gpt-5",
permissionMode: "auto",
projectId: "proj_test",
providerId: "acp-amp",
providerSupportsFork: true,
reasoningLevel: "medium",
serviceTier: undefined,
sourceSeqEnd: undefined,
sourceThreadId: "thr_source",
sourceThreadTitle: "Investigate flaky test",
}),
).toMatchObject({
originKind: "fork",
providerId: "acp-amp",
sourceThreadId: "thr_source",
});
});
it("returns null when the provider cannot fork sessions", () => {
expect(
buildForkThreadRequest({
environmentId: "env_source",
input: [{ type: "text", text: "Continue from here", mentions: [] }],
model: "unknown-model",
permissionMode: "auto",
projectId: "proj_test",
providerId: "not-a-provider",
providerSupportsFork: false,
reasoningLevel: "medium",
serviceTier: undefined,
sourceSeqEnd: undefined,
sourceThreadId: "thr_source",
sourceThreadTitle: "Investigate flaky test",
}),
).toBeNull();
});
});
describe("isThreadForkable", () => {
it("rejects an archived source and permits it after unarchiving", () => {
const source = makeThread({ archivedAt: 123 });
expect(isThreadForkable(source, true)).toBe(false);
expect(isThreadForkable({ ...source, archivedAt: null }, true)).toBe(true);
});
it("is true only with an environment id and a fork-capable provider", () => {
expect(
isThreadForkable(makeThread({ environmentId: "env_source" }), true),
).toBe(true);
expect(isThreadForkable(makeThread({ environmentId: null }), true)).toBe(
false,
);
expect(
isThreadForkable(makeThread({ providerId: "not-a-provider" }), false),
).toBe(false);
expect(isThreadForkable(null, true)).toBe(false);
});
});
5. Root cause
The helper only includes environmentId and providerId in its input type. Its body returns false for a missing source or environment, then returns the provider capability without consulting archivedAt.
if (sourceThread === null || sourceThread.environmentId === null) {
return false;
}
return providerSupportsFork;ThreadDetailView uses that result and supplies the timeline fork handler when true. The fork hook checks the same helper before fetching execution defaults and navigating. MessageActionBar includes the fork action when a callback exists. Meanwhile, thread creation rejects archived sources with sourceThreadId must reference an unarchived thread. The dedicated fork service also rejects archived sources. This is an omitted client lifecycle check against an existing server policy.
6. Proposed fix
Require archivedAt in the shared thread eligibility input and return false when it is non-null. Both existing callers already pass complete threads, so this suppresses the action and prevents the hook from initiating a fork for an archived source. Keep request construction checking provider capability directly: its validated seed has an environment but is not a live thread snapshot. Unarchiving restores eligibility. The server remains authoritative if a source is archived after the composer opens.
The candidate changes 25 text lines (14 additions, 11 deletions) across two existing client-core files. No dependency, stored-data change, migration, generated file, public wire/schema change, or server policy change. The test fails before the fix and passes afterward, including the unarchive control.
7. Verification
The same agent created a second detached clean worktree named verify at the exact recorded base, ran a separate frozen install, copied only the authored regression test, and reran the same Turbo command. It again failed only the archive eligibility assertion (1 failed, 5 passed). Both test executions ran live, not from cached test output. There were no application ports or data directories to reuse. The second result supported the report without correction; this is a repeated check by the same agent.
Main later advanced to 1854189dc7de1a51b13a2e82ddc0b179661df187. Its changes did not modify the eligibility helper. The candidate was rebased and verified there: 261 client-core tests, 71 focused app tests, client-core and app typechecks pass. Detailed test results appear below. All root-cause links above refer to the original reproduction commit.
8. Related issues and pull requests
Issue cross-reference metadata contained no linked pull requests, and the open-PR search for #3375 returned none at investigation time. No linked branch was fetched or run. No related issue was needed to establish the root cause.
9. Appendix and limits
Trusted-base build completed with 20 successful tasks. Commands also included git fetch origin main, git rev-parse, git diff --check, git diff --numstat origin/main, and the focused app Turbo test/typecheck commands used for verification. Local checkout paths were removed from published logs. Issue content was treated as untrusted claims; no issue scripts or external links were executed or fetched. Full browser rendering, HTTP submission, desktop, and Connect behavior remain unverified. No database was mocked and no live user store was accessed.
before: sanitized execution excerpt
@bb/client-core:test: ❯ |@bb/client-core| test/fork-thread-request.test.ts (6 tests | 1 failed) 6ms
@bb/client-core:test: ✓ reuses the source environment and starts with the user's first message 1ms
@bb/client-core:test: ✓ omits unsupported service tier 0ms
@bb/client-core:test: ✓ builds a fork request for a generic ACP provider 0ms
@bb/client-core:test: ✓ returns null when the provider cannot fork sessions 0ms
@bb/client-core:test: × rejects an archived source and permits it after unarchiving 3ms
@bb/client-core:test: ✓ is true only with an environment id and a fork-capable provider 0ms
@bb/client-core:test:
@bb/client-core:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
@bb/client-core:test:
@bb/client-core:test: FAIL |@bb/client-core| test/fork-thread-request.test.ts > isThreadForkable > rejects an archived source and permits it after unarchiving
@bb/client-core:test: AssertionError: expected true to be false // Object.is equality
@bb/client-core:test:
@bb/client-core:test: - Expected
@bb/client-core:test: + Received
@bb/client-core:test:
@bb/client-core:test: - false
@bb/client-core:test: + true
@bb/client-core:test:
@bb/client-core:test: ❯ test/fork-thread-request.test.ts:122:44
@bb/client-core:test: 120| const source = makeThread({ archivedAt: 123 });
@bb/client-core:test: 121|
@bb/client-core:test: 122| expect(isThreadForkable(source, true)).toBe(false);
@bb/client-core:test: | ^
@bb/client-core:test: 123| expect(isThreadForkable({ ...source, archivedAt: null }, true)).to…
@bb/client-core:test: 124| });
@bb/client-core:test:
@bb/client-core:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
@bb/client-core:test:
@bb/client-core:test:
@bb/client-core:test: Test Files 1 failed (1)
@bb/client-core:test: Tests 1 failed | 5 passed (6)
@bb/client-core:test: Start at 17:54:29
@bb/client-core:test: Duration 201ms (transform 42ms, setup 0ms, import 57ms, tests 6ms, environment 0ms)
@bb/client-core:test:
@bb/client-core:test: ELIFECYCLE Test failed. See above for more details.
@bb/client-core:test: ERROR: command finished with error: command (<checkout>/packages/client-core) <temporary-work>/bin/pnpm run test --run test/fork-thread-request.test.ts exited (1)
@bb/client-core#test: command (<checkout>/packages/client-core) <temporary-work>/bin/pnpm run test --run test/fork-thread-request.test.ts exited (1)
Tasks: 1 successful, 2 total
Cached: 0 cached, 2 total
Time: 2.27s
Failed: @bb/client-core#test
ERROR run failed: command exited (1)verify-before: sanitized execution excerpt
@bb/client-core:test: ❯ |@bb/client-core| test/fork-thread-request.test.ts (6 tests | 1 failed) 6ms
@bb/client-core:test: ✓ reuses the source environment and starts with the user's first message 1ms
@bb/client-core:test: ✓ omits unsupported service tier 0ms
@bb/client-core:test: ✓ builds a fork request for a generic ACP provider 0ms
@bb/client-core:test: ✓ returns null when the provider cannot fork sessions 0ms
@bb/client-core:test: × rejects an archived source and permits it after unarchiving 3ms
@bb/client-core:test: ✓ is true only with an environment id and a fork-capable provider 0ms
@bb/client-core:test:
@bb/client-core:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
@bb/client-core:test:
@bb/client-core:test: FAIL |@bb/client-core| test/fork-thread-request.test.ts > isThreadForkable > rejects an archived source and permits it after unarchiving
@bb/client-core:test: AssertionError: expected true to be false // Object.is equality
@bb/client-core:test:
@bb/client-core:test: - Expected
@bb/client-core:test: + Received
@bb/client-core:test:
@bb/client-core:test: - false
@bb/client-core:test: + true
@bb/client-core:test:
@bb/client-core:test: ❯ test/fork-thread-request.test.ts:122:44
@bb/client-core:test: 120| const source = makeThread({ archivedAt: 123 });
@bb/client-core:test: 121|
@bb/client-core:test: 122| expect(isThreadForkable(source, true)).toBe(false);
@bb/client-core:test: | ^
@bb/client-core:test: 123| expect(isThreadForkable({ ...source, archivedAt: null }, true)).to…
@bb/client-core:test: 124| });
@bb/client-core:test:
@bb/client-core:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
@bb/client-core:test:
@bb/client-core:test:
@bb/client-core:test: Test Files 1 failed (1)
@bb/client-core:test: Tests 1 failed | 5 passed (6)
@bb/client-core:test: Start at 17:54:50
@bb/client-core:test: Duration 121ms (transform 20ms, setup 0ms, import 27ms, tests 6ms, environment 0ms)
@bb/client-core:test:
@bb/client-core:test: ELIFECYCLE Test failed. See above for more details.
@bb/client-core:test: ERROR: command finished with error: command (<temporary-work>/verify/packages/client-core) <temporary-work>/bin/pnpm run test --run test/fork-thread-request.test.ts exited (1)
@bb/client-core#test: command (<temporary-work>/verify/packages/client-core) <temporary-work>/bin/pnpm run test --run test/fork-thread-request.test.ts exited (1)
Tasks: 1 successful, 2 total
Cached: 0 cached, 2 total
Time: 2.263s
Failed: @bb/client-core#test
ERROR run failed: command exited (1)after: sanitized execution excerpt
@bb/client-core:test: ✓ |@bb/client-core| test/prompt-draft.test.ts (18 tests) 3ms @bb/client-core:test: ✓ |@bb/client-core| test/terminal-websocket-transport.test.ts (11 tests) 28ms @bb/client-core:test: ✓ |@bb/client-core| test/mention-suggestion-order.test.ts (5 tests) 1ms @bb/client-core:test: ✓ |@bb/client-core| test/threadQueuedMessages.test.ts (4 tests) 1ms @bb/client-core:test: @bb/client-core:test: Test Files 21 passed (21) @bb/client-core:test: Tests 261 passed (261) @bb/client-core:test: Start at 17:56:16 @bb/client-core:test: Duration 2.96s (transform 9.62s, setup 0ms, import 14.19s, tests 125ms, environment 1ms) @bb/client-core:test: Tasks: 3 successful, 3 total Cached: 0 cached, 3 total Time: 4.655s
app-tests: sanitized execution excerpt
@bb/app:test: @bb/app:test: ✓ |@bb/app:isolated| src/components/thread/timeline/MessageActionBar.test.tsx (34 tests) 1255ms @bb/app:test: ✓ |@bb/app:isolated| src/hooks/useForkThreadFromMessage.test.tsx (2 tests) 17ms @bb/app:test: ✓ |@bb/app:isolated| src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx (35 tests) 844ms @bb/app:test: @bb/app:test: Test Files 3 passed (3) @bb/app:test: Tests 71 passed (71) @bb/app:test: Start at 17:56:18 @bb/app:test: Duration 4.88s (transform 3.70s, setup 528ms, import 6.06s, tests 2.12s, environment 2.82s) @bb/app:test: Tasks: 4 successful, 4 total Cached: 1 cached, 4 total Time: 8.086s
app-typecheck: sanitized execution excerpt
@bb/templates:generate:templates: cache miss, executing faf5178646d6a21e @bb/templates:generate:templates: @bb/templates:generate:templates: > @bb/templates@0.0.1 generate:templates <checkout>/packages/templates @bb/templates:generate:templates: > node ./scripts/generate-templates.mjs @bb/templates:generate:templates: @bb/app:typecheck: cache miss, executing 2b57dd3f793afeac @bb/app:typecheck: @bb/app:typecheck: > @bb/app@0.0.1 typecheck <checkout>/apps/app @bb/app:typecheck: > tsc --noEmit && tsc --noEmit -p tsconfig.node.json @bb/app:typecheck: Tasks: 3 successful, 3 total Cached: 1 cached, 3 total Time: 7.024s
pnpm exec turbo run build pnpm exec turbo run test typecheck --filter=@bb/client-core pnpm exec turbo run test --filter=@bb/app -- --run src/hooks/useForkThreadFromMessage.test.tsx src/components/thread/timeline/MessageActionBar.test.tsx src/components/thread/timeline/ThreadTimelineRows.actions.test.tsx pnpm exec turbo run typecheck --filter=@bb/app