[BUG] Fable 5 thinking blocks come back empty ("thinking":"") in VS Code extension 2.1.233 — Opus 5 unaffected, worked on 2.1.228

Status Open
Reported on v2.1.233
Maintainer reply None cached
Activity 4 comments · opened Aug 15, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report
  • [x] I am using the latest version of Claude Code (VS Code extension 2.1.233)

What's Wrong?

Since the VS Code extension updated to 2.1.233, thinking blocks returned for claude-fable-5 arrive with an empty thinking string (signature only). The UI shows "Thought for Ns" with a chevron, but expanding it shows nothing — there is no thinking text to display. Focus view is off. showThinkingSummaries: true is set in ~/.claude/settings.json.

The same client with claude-opus-5 returns thinking text normally, so this is model-specific (Fable 5) and version-specific (2.1.233).

Evidence from the session transcripts in ~/.claude/projects/<project>/*.jsonl:

  • Every thinking block written by client 2.1.233 for claude-fable-5 looks like:

{"type":"thinking","thinking":"","signature":"CAIS..."}

  • One session spans the update and shows the flip inside a single conversation:
  • lines with "version":"2.1.228", model claude-fable-5 → thinking text present
  • lines with "version":"2.1.233", model claude-fable-5 (from 2026-08-15T06:43Z onward) → "thinking":"" on every block
  • Across the last ~25 sessions: ~1,900 Fable 5 thinking blocks on 2.1.228 had text; 0 of ~90 on 2.1.233 do.
  • Opus 5 sessions on 2.1.233: thinking text present.

Nothing in the changelog for 2.1.229–2.1.233 mentions a change to thinking display.

What Should Happen?

Fable 5 thinking summaries should be shown (as they were on 2.1.228 with the same settings), or if summaries are intentionally unavailable for this model, the UI should say so instead of showing an expandable "Thought for Ns" that is empty.

Error Messages/Logs

# transcript line shape (2.1.233, claude-fable-5)
{"type":"thinking","thinking":"","signature":"CAIS..."}

No error output; the request succeeds.

Steps to Reproduce

  1. VS Code + Claude Code extension 2.1.233 (macOS, darwin-x64). ~/.claude/settings.json contains "showThinkingSummaries": true, model set to claude-fable-5 (also reproduces with claude-fable-5[1m]).
  2. Make sure Focus view is off and Extended thinking is on in the / menu.
  3. Ask anything that triggers thinking (e.g. "think for a moment").
  4. Observe "Thought for Ns" with a chevron; expanding shows no text.
  5. Open the session's .jsonl under ~/.claude/projects/... — every thinking block has "thinking": "".
  6. Switch model to Opus 5 (/model) and repeat — thinking text is present.

Claude Model

Other — claude-fable-5 (also claude-fable-5[1m]); Opus 5 unaffected

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.228 (VS Code extension) — Fable 5 thinking text was present up to the moment the extension updated to 2.1.233 mid-session.

Current Version

VS Code extension 2.1.233 (anthropic.claude-code-2.1.233-darwin-x64); standalone CLI on the machine is 2.1.218 (not used for these sessions).

Environment

  • OS: macOS (Darwin 25.5.0)
  • Client: Claude Code VS Code extension 2.1.233
  • Auth: Claude subscription (not API key)
  • Effort level: xhigh

View original on GitHub ↗

3 Comments

JeffvonD · 15 days ago

Root-caused this on our side (CLI 2.1.233, macOS, claude-fable-5, showThinkingSummaries: true; same symptom as above and as in my duplicate #86913: 366/366 Fable thinking blocks empty today, Opus 5 fine, the 2.1.227 binary fine).

The empty summaries are triggered by the request header x-cc-atis: <16-hex> that 2.1.233 sends on first-party requests. Everything else in the request is irrelevant. Method: a local logging/rewriting forwarder on ANTHROPIC_BASE_URL (with _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1 so the client keeps all first-party behaviour), same prompt, --thinking-display summarized, then removing one request feature at a time:

| request variant (2.1.233, Fable 5, thinking: {type:"adaptive", display:"summarized"} on the wire) | thinking_tokens | thinking block on the wire |
|---|---:|---|
| unmodified (first-party) | 404 | empty |
| minus advanced-tool-use-2025-11-20 beta + deferred/eager tool flags + DeferredToolPlaceholder | 781 | empty |
| minus cache-diagnosis-2026-04-07 beta + diagnostics body field | 440 | empty |
| minus the extra first-party system blocks | 437 | empty |
| minus x-client-request-id header | 508 | empty |
| minus x-cc-atis header | 1170 | summary text (900 chars) |
| minus x-cc-atis header (repeat) | 1290 | summary text (1032 chars) |

Interactive sessions behave the same: a normal claude start through the plain forwarder (which, being non-first-party, never sends x-cc-atis) shows summaries in the TUI again; a normal direct start does not.

Where the header comes from: the value is not computed client-side, it is echoed from server-delivered client data cached in ~/.claude.json under clientDataCacheSlots.bi1-<hash>.data — next to experimentKey: "claude_code_ribbon_boulevard_experiment", cedar_lagoon: {"claude-fable": true, "claude-mythos": true} and cedar_basin: "2026-08-31". On this machine that slot was first written 2026-08-15T09:55Z (start of the first affected session). The 2.1.227 binary on the same machine/account sends no x-cc-atis and gets summaries; a static diff of the 2.1.227/2.1.228/2.1.233 bundles shows the showThinkingSummariesthinking.display request path is identical (the header code itself also already exists in 2.1.227 — it just has no assignment to send). So this looks like a server-side treatment keyed on that experiment assignment that drops Fable 5 summaries even when the request asks for display: "summarized"; Opus 5 requests carrying the same header are unaffected.

Two side notes for anyone reproducing:

  • -p/headless mode forces display: "omitted" unless you pass --thinking-display summarized, so headless tests look empty for every model — test interactively or with that flag.
  • The hidden --thinking-display summarized flag and showThinkingSummaries are not a workaround here; the request already carries display: "summarized".

Workarounds until the treatment is fixed: run the 2.1.227 binary (~/.local/share/claude/versions/2.1.227), or put a local forwarder on ANTHROPIC_BASE_URL that deletes x-cc-atis (with _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1 remote control / tool search keep working). Happy to share the forwarder script or raw captures.

JeffvonD · 15 days ago

Follow-up to my comment above: the x-cc-atis treatment is not only a display issue — it also reduces how much Fable 5 thinks. Measured with usage.output_tokens_details.thinking_tokens (which 2.1.233 records per response; it counts internal thinking, not the summary text — we saw 19k thinking tokens on a response whose summary block was empty).

18 fresh paired probes tonight (claude -p --model claude-fable-5 --effort xhigh, clean env, same prompt per pair; "with header" = direct to api.anthropic.com, "without" = through the local forwarder that only deletes x-cc-atis):

| probe (2.1.233, Fable 5, effort xhigh) | without x-cc-atis | with x-cc-atis | Mann-Whitney (exact) |
|---|---|---|---|
| light prompt (4-person bridge puzzle): responses with 0 thinking tokens | 0 / 6 | 5 / 6 (the 6th: 191) | p = 0.0022 |
| light prompt: median thinking tokens | 246 | 0 | — |
| heavier prompt (6-person puzzle), n = 3 vs 3: median thinking tokens | 974 | 408 (≈2.4× less) | p = 0.05 (max at 3v3) |
| same heavier prompt, our earlier interactive replays, n = 6 vs 6 | 971 | 438 (≈2.2× less) | p = 0.0043 |

duration_api_ms scales with it (6-person puzzle: 16–22 s vs 8–11 s), so these are real thinking tokens. All 18 answers were correct in both arms, so we can't say anything about quality on real work; an explicit "ultrathink" prompt breaks the damping (1.5k–3.8k tokens with the header), so it looks like the treatment dampens the adaptive decision to think rather than blocking forced deep thinking. Arm assignment is inferred from the base URL (direct ⇒ header sent), per the bisect above.

Practical consequence: the forwarder/2.1.227 workaround is quality-relevant, not cosmetic. Details, scripts and probe transcripts available on request.

danra · 12 days ago

TL;DR: Reproduced the same issue. Use the hook at the end to fix

Independent confirmation of @JeffvonD's root cause, from a different machine/account (native CLI on macOS, claude.ai subscription via OAuth, claude-fable-5[1m], effortLevel: xhigh, showThinkingSummaries: true, verbose: true).

Session history (all thinking blocks in ~/.claude/projects/*/*.jsonl, split by whether the thinking string is non-empty):

| CLI | model | date | non-empty | empty |
|---|---|---|---:|---:|
| 2.1.232 | Fable 5 | 08-14 | 169 | 0 |
| 2.1.232 | Opus 5 | 08-14 | 372 | 0 |
| 2.1.233 | Fable 5 | 08-15..08-17 | 0 | 398 |
| 2.1.233 | Opus 5 | 08-15..08-17 | 1033 | 0 |
| 2.1.234 | Fable 5 | 08-17 | 0 | 36 |

On the wire. Local logging forwarder on ANTHROPIC_BASE_URL with _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1, one-shot -p requests:

| client / model | x-cc-atis header |
|---|---|
| 2.1.234 / Fable 5 | present (16-hex value) |
| 2.1.234 / Opus 5 | absent |
| 2.1.232 binary / Fable 5, before its client-data cache refreshed | absent |
| 2.1.232 binary / Fable 5, after an interactive session refreshed it | present (same value) |

Source of the value on this machine matches the description above: ~/.claude.jsonclientDataCacheSlots.bi1-<hash>.data.atis, present only in the Fable 5 slots written by 2.1.233+ and sitting next to experimentKey: "claude_code_<redacted>_experiment"; the Opus 5 slots have neither key. The 2.1.232-era Fable 5 slot had neither key either — until I started an interactive 2.1.232 session later, at which point that slot was refreshed, received atis + the experiment key, and 2.1.232 began sending the header (and returning empty summaries) as well. So the assignment is not gated on client version; an older binary only helps for as long as its cached client data predates the assignment. Checking release binaries from downloads.claude.ai confirms there is no recent version to go back to: the header-sending fetch-wrapper code and the atis getter are present unchanged in every version from 2.1.224 through 2.1.234 (2.1.230 wasn't downloadable). What changed on 08-15 is the server starting to deliver the assignment, not the client.

Two more behaviors worth noting for anyone experimenting:

  • The getter re-reads ~/.claude.json on every request, so deleting the cached atis takes effect live in running sessions — summaries reappear on the next turn, no restart needed.
  • The assignment is re-fetched during every session start (a fresh session sends the header on its very first /v1/messages call) and came back with the identical value each time here, so it's a stable server-side assignment, not a per-session draw.

In the 2.1.234 bundle the fetch wrapper does (deminified): if (isFirstParty) { const v = getClientDataAtis(); if (v !== undefined) headers.set("x-cc-atis", v) }, and getClientDataAtis() just reads clientData?.atis from that cache — no client-side computation, no model check.

Causal test on this account. Same prompt (4-person bridge puzzle), claude -p --model claude-fable-5 --effort xhigh --thinking-display summarized --output-format stream-json --verbose, run through the forwarder; body carries thinking: {"type":"adaptive","display":"summarized"} in every case. Only difference between arms is whether the forwarder deletes x-cc-atis:

| run | x-cc-atis forwarded upstream | thinking block on the wire | output_tokens (result event) |
|---|---|---|---:|
| 2.1.234, header kept | yes | thinking: "" | 85 |
| 2.1.234, header stripped | no | summary text, 302 chars | 216 |
| 2.1.234, header stripped (repeat) | no | summary text, 292 chars | 143 |
| 2.1.232 binary, stale cache (no header yet) | no | summary text, 139 chars | 37 |

Answer was correct (17) in every arm. (The output_tokens column is the whole-response count, internal thinking included; the dedicated thinking_tokens measurement is in the next section.)

Less thinking replication. Testing with the x-cc-atis header vs. without it (repro scripts below):

<table>
<tr><th colspan="2">prompt (n=8 vs 8)</th><th>with header</th><th>without header</th></tr>
<tr><td rowspan="2">light (4-person bridge)</td><td>thinking_tokens</td><td><b>0 in 8/8 runs</b></td><td>median 77, never 0</td></tr>
<tr><td>median API duration</td><td>1.8s</td><td>6.0s</td></tr>
<tr><td rowspan="2">heavy (6-person bridge)</td><td>thinking_tokens</td><td>median <b>444</b> (305-578)</td><td>median <b>696</b> (530-814)</td></tr>
<tr><td>median API duration</td><td>6.4s</td><td>12.5s</td></tr>
</table>

  • The durations scaling with the token counts indicates real compute is being skipped.
  • The header has two separable effects: it dampens the adaptive decision to think (fewer thinking tokens, down to zero on the light prompt), and it blanks the summary text of whatever thinking still happens — the heavy with-header runs thought 305-578 tokens, yet all 8 of their summary blocks came back empty (consistent with the 19k-thinking-tokens-but-empty-summary observation above).
  • Quality: 31/32 runs answered correctly (17 / 42); the one miss was a with-header light run (0 thinking tokens) that described the crossing strategy but omitted the requested number — n=1, an anecdote only.
  • Magnitude here is ~1.6x on the heavy prompt vs the ~2.2-2.4x reported above — same direction and shape (light prompts collapse to zero thinking; harder prompts dampen).

<details>
<summary>Repro scripts (forwarder + batch)</summary>

fwd_exp.py:

import http.client, http.server, json, os, re, sys, threading, time

MODE = os.environ["FWD_MODE"]
lock = threading.Lock()

def log_line(rec):
    with lock, open(os.environ["FWD_LOG"], "a") as f:
        f.write(json.dumps(rec) + "\n")

class Handler(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def _proxy(self):
        body = self.rfile.read(int(self.headers.get("content-length") or 0)) or None
        hdrs = {k: v for k, v in self.headers.items() if k.lower() not in
                {"host", "content-length", "transfer-encoding", "connection", "keep-alive", "accept-encoding", "x-cc-atis"}}
        if MODE == "inject": hdrs["x-cc-atis"] = os.environ["FWD_ATIS"]
        try: model = json.loads(body).get("model") if body else None
        except ValueError: model = None
        t0 = time.time()
        conn = http.client.HTTPSConnection("api.anthropic.com")
        try:
            conn.request(self.command, self.path, body=body, headers=hdrs)
            r = conn.getresponse()
            self.send_response(r.status)
            for k, v in r.getheaders():
                if k.lower() not in {"transfer-encoding", "content-length", "connection", "content-encoding"}:
                    self.send_header(k, v)
            self.send_header("Transfer-Encoding", "chunked"); self.end_headers()
            tail = b""  # usage arrives in the final SSE events
            while chunk := r.read1(65536):
                self.wfile.write(b"%x\r\n" % len(chunk) + chunk + b"\r\n"); self.wfile.flush()
                tail = (tail + chunk)[-131072:]
            self.wfile.write(b"0\r\n\r\n"); self.wfile.flush()
            if "/v1/messages" in self.path and model:
                tt = re.findall(rb'"thinking_tokens":(\d+)', tail)
                ot = re.findall(rb'"output_tokens":(\d+)', tail)  # cumulative; last one wins
                log_line({"model": model, "mode": MODE, "status": r.status,
                          "thinking_tokens": int(tt[-1]) if tt else None,
                          "output_tokens": int(ot[-1]) if ot else None,
                          "dur": round(time.time() - t0, 2)})
        except Exception as e:
            log_line({"error": repr(e), "path": self.path})
        finally:
            conn.close()

    do_GET = do_POST = do_HEAD = _proxy

srv = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler)
srv.daemon_threads = True
srv.handle_error = lambda *a: None  # client disconnects are routine
srv.serve_forever()

batch.sh:

#!/bin/bash
set -u
SCRIPT_PATH="$(dirname "$(realpath "$0")")"
PORT=8802

cached_atis="$(jq -r 'first(.clientDataCacheSlots[]?.data.atis // empty)' "$HOME/.claude.json")"
ATIS="${ATIS:-$cached_atis}"
[ -n "$ATIS" ] || { echo "no atis assignment cached; run a Fable 5 session first or export ATIS" >&2; exit 1; }

LIGHT="Alice, Bob, Carol and Dave must cross a bridge at night with one flashlight; at most two cross at a time and they must carry the flashlight; they take 1, 2, 5 and 10 minutes respectively, and a pair walks at the slower speed. What is the minimum total time? Answer with just the number of minutes."
HEAVY="Six hikers must cross a bridge at night with one flashlight; at most two cross at a time and they must carry the flashlight; they take 1, 2, 5, 10, 15 and 20 minutes respectively, and a pair walks at the slower speed. What is the minimum total time for all six to cross? Answer with just the number of minutes."

run_one() {
  local prompt_id="$1" mode="$2" idx="$3" prompt="$4"
  local log="$SCRIPT_PATH/b-$prompt_id-$mode-$idx.log" out="$SCRIPT_PATH/b-$prompt_id-$mode-$idx.out"
  : > "$log"
  FWD_MODE="$mode" FWD_ATIS="$ATIS" FWD_LOG="$log" python3 "$SCRIPT_PATH/fwd_exp.py" "$PORT" &
  local fwd_pid=$!
  sleep 1
  ANTHROPIC_BASE_URL="http://127.0.0.1:$PORT" _CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL=1 \
    claude -p "$prompt" --model claude-fable-5 --effort xhigh --thinking-display summarized \
    --output-format stream-json --verbose < /dev/null > "$out" 2>/dev/null
  kill "$fwd_pid" 2>/dev/null; wait "$fwd_pid" 2>/dev/null || true
}

for prompt_id in light heavy; do
  if [ "$prompt_id" = light ]; then prompt="$LIGHT"; else prompt="$HEAVY"; fi
  for i in $(seq 1 8); do
    run_one "$prompt_id" inject "$i" "$prompt"
    run_one "$prompt_id" strip "$i" "$prompt"
  done
done

</details>

Net: the same client version, account, prompt and request body get summaries back iff x-cc-atis is removed, so this reproduces as a server-side treatment keyed on that experiment assignment for Fable 5. Workarounds confirmed here (pinning an older binary is not one — see above):

  • a local forwarder on ANTHROPIC_BASE_URL that deletes x-cc-atis;
  • lighter: a SessionStart hook that strips the atis keys from clientDataCacheSlots in ~/.claude.json (plus a short background re-strip loop to cover the startup re-fetch). Confirmed here: on a fresh interactive session the startup re-fetch wrote atis and the loop stripped it 1s later — thanks to the live re-read above, summaries were present from the first turn. Setup:

<details>
<summary>SessionStart hook setup</summary>

Save as ~/.claude/hooks/strip-x-cc-atis.sh and chmod +x it:

#!/bin/bash
# SessionStart hook: strip the `atis` experiment assignment from ~/.claude.json
# clientDataCacheSlots so Claude Code stops sending the `x-cc-atis` header, which
# server-side reduces Fable 5 thinking and blanks its thinking summaries
# (anthropics/claude-code#86865).
#
# The client re-fetches the assignment at each session start and may write it
# shortly AFTER this hook runs, so after one immediate strip we keep re-stripping
# every 2s for 2 minutes in a detached background loop. Sessions re-read the
# config on every request, so a strip takes effect on the very next API call.
LOG="${TMPDIR:-/tmp}/claude-strip-atis.log"

strip_once() {
  python3 - <<'PY'
import json, os, tempfile, time
p = os.path.expanduser("~/.claude.json")
try:
    with open(p) as f:
        d = json.load(f)
except Exception:
    raise SystemExit(0)  # unreadable/mid-write; next tick will retry
changed = []
for key, slot in (d.get("clientDataCacheSlots") or {}).items():
    data = slot.get("data")
    if isinstance(data, dict) and "atis" in data:
        del data["atis"]
        changed.append(key)
if isinstance(d.get("clientDataCache"), dict) and "atis" in d["clientDataCache"]:
    del d["clientDataCache"]["atis"]
    changed.append("clientDataCache")
if not changed:
    raise SystemExit(0)
# Atomic replace: minimize the chance of clobbering a concurrent write by Claude Code.
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(p), prefix=".claude.json.strip-atis.")
with os.fdopen(fd, "w") as f:
    json.dump(d, f, indent=2)
os.replace(tmp, p)
print(time.strftime("%Y-%m-%dT%H:%M:%S"), "stripped atis from:", ",".join(changed))
PY
}

strip_once >> "$LOG" 2>&1
# Detached re-strip loop covering the startup fetch race.
( for _ in $(seq 1 60); do sleep 2; strip_once; done >> "$LOG" 2>&1 & ) < /dev/null > /dev/null 2>&1
exit 0

Register it in ~/.claude/settings.json:

"hooks": {
  "SessionStart": [
    { "hooks": [ { "type": "command", "command": "\"$HOME\"/.claude/hooks/strip-x-cc-atis.sh" } ] }
  ]
}

</details>

Showing cached comments. Read the full discussion on GitHub ↗