← reports

#3257 · Follow-up reasoning selection is not saved as the thread default

Bug Medium Effort: Low threads ui open on GitHub 2026-09-08 · base d5103c449

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high

1. TL;DR

A follow-up composer can send a turn with a newly selected reasoning level while leaving the thread's saved default unchanged. In an isolated main-branch run, the follow-up request used High and the default-execution endpoint continued to return Max. The selector remained High through turn completion in that run, so the reported immediate reset was not reproduced; after a page reload, it rehydrated to Max. The client only updates component-local state and the per-turn request, even though the existing thread update endpoint can save a reasoning override.

2. Claims vs findings

ClaimStatusEvidence
The chosen follow-up level is used for the submitted turn.VerifiedThe isolated event row for the second request recorded reasoningLevel: high.
The chosen level is not saved as the thread override.VerifiedAfter the turn completed, the default-execution endpoint still returned reasoningLevel: max.
The selector immediately returns to Max after sending.Not reproducedIt stayed High through completion and returned to Max only after reload in the isolated Codex-provider run.
The behavior is caused by provider translation.RefutedThe mismatch exists before provider translation: client state is local, the turn request carries High, and no thread update is sent.

3. Environment

4. Minimal reproduction

  1. Start the trusted checkout's isolated dev app and create a test thread with a saved Max reasoning override.
  2. Open that thread, choose High in the composer, enter a minimal follow-up, and submit.
  3. Wait for the turn to finish, then read the isolated event row and the thread default-execution endpoint.
  4. Reload the page and inspect the selector again.
Expected saved default: { "reasoningLevel": "high" }
Actual saved default:   { "reasoningLevel": "max" }
Submitted turn:         { "reasoningLevel": "high" }
Selector after turn:    High
Selector after reload:  Max
Composer showing High reasoning before the follow-up is submitted
Before submission: the thread started with Max saved, then the composer was changed to High.
Composer still showing High reasoning after the follow-up completed
After turn completion: High remained visible, which refutes the exact immediate-reset timing in this environment.
Composer showing Max reasoning after a page reload
After reload: the selector rehydrated from the unchanged saved default and returned to Max.

Focused regression test

The test drives the real prompt-area change handler and requires it to invoke the existing thread update mutation. The complete runnable file is saved with the report.

it("persists a changed reasoning level as the thread default", () => {
  mocks.defaultExecutionOptions = {
    model: "gpt-5",
    permissionMode: "auto",
    reasoningLevel: "max",
    serviceTier: "default",
    source: "client/turn/requested",
  };

  renderPromptArea();
  fireEvent.click(
    screen.getByRole("button", { name: "Select high reasoning" }),
  );

  expect(mocks.updateThreadMutate).toHaveBeenCalledWith({
    id: "thr_1",
    reasoningLevel: "high",
  });
});
First checkout: 1 failed, 28 skipped
Expected update mutation with High; actual calls: 0

Second clean checkout: 1 failed, 28 passed
The same assertion failed at the same trusted commit.

5. Root cause

The follow-up prompt area initializes its execution selector from the thread's resolved defaults but explicitly places the selector in component-local scope. The reasoning picker then receives the local setter directly. Submission copies that local selection into the per-turn request; no thread update mutation is called.

reasoning: {
  value: reasoningLevel,
  options: reasoningOptions,
  onChange: setReasoningLevel,
}

This explains both observed facts: High reaches the submitted turn because submission reads local state, while a fresh mount returns to Max because the persisted thread override was never changed. The immediate post-send reset depends on a remount or rehydration event and did not occur in the isolated Codex run.

6. Proposed fix (first principles)

Wrap the reasoning picker change handler so it updates local selection immediately and calls the existing useUpdateThread mutation with the thread ID and selected reasoning level. Reuse the existing mutation error reporting. The focused component test should fail before the change and pass afterward, followed by the complete prompt-area suite, app typecheck, and app build.

7. Related issues

No duplicate or linked open pull request was found. Repository history shows that thread model and reasoning overrides were introduced as sticky thread-level settings in commit 1a5620b53, while the follow-up picker remained component-local.

8. Verification

The same agent repeated the regression test in a second clean detached checkout at d5103c44944cf139330907124a1db65bac64e18e. The added assertion failed with zero update calls, and all other 28 tests in the file passed. No report claim was removed after the second run. The live run refined the report from a full reproduction to a partial reproduction because the selector stayed High until reload.

9. Appendix

Commands were run only against the trusted repository and isolated dev instance:

pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
pnpm exec turbo run test --filter=@bb/app -- --run src/views/thread-detail/ThreadDetailPromptArea.test.tsx
scripts/bb-dev-app current
pnpm bb:dev thread spawn --project <isolated-project> --new-environment worktree --provider codex --reasoning-level max --permission-mode auto --prompt "Reply only with ok." --json
curl PATCH /api/v1/threads/<isolated-thread> { "reasoningLevel": "max" }
curl GET /api/v1/threads/<isolated-thread>/default-execution-options

Sanitized results: repro/result.txt. The issue content was treated as untrusted data; no issue-provided link, branch, patch, script, or binary was opened or executed.