#3375 · Fork eligibility omits archive state

BugPriority: MediumEffort: LowthreadsGitHub issue · 2026-09-10

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

ClaimFindingEvidence
Archived sources remain eligible for the fork actionVerified at the shared eligibility functionExpected false; received true in both runs. The view and hook use this function.
Creation rejects an archived sourceVerified by source inspection; HTTP request not replayedrequireLiveSourceThread throws a 400 invalid_request for archivedAt !== null.
Occurs in installed desktop 0.42.1 through ConnectUnverified platform detailNo 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