[BUG] /btw side question ends silently after a short preamble when the model attempts a tool call — no answer, no notice, and the truncated turn poisons subsequent side questions
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
What's Wrong?
Scope: I have reproduced this repeatedly on the Claude Code macOS desktop app. I have not
verified it on the terminal CLI — I use the CLI too rarely to say either way, so please read every
observation below as desktop-app-only. The code paths quoted under Mechanism are from the shared
binary, so I would expect the CLI to behave the same, but I have not confirmed that.
A /btw side question frequently returns only a short preamble sentence — e.g. "Let me check that
file." — and then the exchange ends. The substantive answer never arrives, and no error, notice,
or status message is shown. From the user's side it is indistinguishable from a hang or a
truncated stream.
Three properties make this worse than a one-off truncation:
- No signal is shown. The CLI already has a notice for exactly this situation —
(The model tried to call <tool> instead of answering directly. Try rephrasing or ask in the — but it never appears in this case. It is reachable only when the
main conversation.)
response contains zero text. Any preamble text suppresses it, and that preamble is then
presented as if it were the complete answer.
- It is sticky within a session, all-or-nothing across sessions. In an affected session,
every subsequent /btw truncates the same way no matter how the question is phrased. In an
unaffected session, /btw works every time. I have not been able to identify the boundary
(new session? /clear? auto-compact?), which is itself a symptom of (1) and (3).
- It leaves no artifact.
/btwexchanges are never written to the session transcript
(~/.claude/projects/<project>/<session>.jsonl), so there is nothing to inspect afterwards.
Users cannot self-diagnose or attach evidence.
Sessions whose context contains strong "call a tool before answering" instructions — a projectCLAUDE.md, or a plugin SessionStart hook injecting "you must invoke a skill before ANY
response" — appear far more likely to be affected. The side agent inherits that context but is
not permitted to use tools.
What Should Happen?
Either of the following:
- The side question produces a real answer. A denied tool call should not end the turn; the model
should get the chance to answer without tools.
- Failing that, the UI must always tell the user what happened — the existing "tried to call a
tool instead of answering directly" notice should be shown in addition to any partial text,
not only when the response contains no text at all.
In no case should a partial preamble be presented as the complete answer, and a truncated exchange
should not be carried forward as context for later /btw questions.
Error Messages/Logs
(nothing is displayed — the absence of any message is the core of this report)
Expected but absent:
(The model tried to call <tool_name> instead of answering directly. Try rephrasing or ask in the main conversation.)
Steps to Reproduce
- In any repository, create a
CLAUDE.mdwith a strong tool-first instruction:
``markdown``
# Project rules
Before producing ANY response — including clarifying questions — you MUST first call a tool
to gather context. This is not optional.
(A plugin whose SessionStart hook injects an equivalent instruction reproduces this too;
that is how I originally hit it.)
- Open that directory in the Claude Code macOS desktop app. (Untested on the terminal CLI.)
- Send one ordinary message so a fork point exists (e.g.
hello). - Ask a side question that invites looking something up:
````
/btw what does the authentication middleware in this repo do?
- Observed: a single short sentence such as "Let me check that file." is returned, then the
exchange ends. No answer, no error, no notice.
- Ask further
/btwquestions in the same session — every one truncates the same way. - Confirm nothing was recorded:
``bash``
grep -c 'side question' ~/.claude/projects/*/<session-id>.jsonl # 0
Note on reproducibility: this is not per-call flaky. A given session either fails on every /btw
or succeeds on every /btw. Step 1 raises the odds of landing in a failing session considerably,
but I cannot yet state a deterministic trigger.
Mechanism
From strings on my own installed build (~/.local/share/claude/versions/2.1.250, Mach-O arm64).
Symbol names are minified; the structure is verbatim.
The side-question turn is dispatched with tool definitions still in the request (thecacheSafeParams object carries the main loop's tools array, presumably so the prompt cache
stays valid) — tool use is forbidden only by a system-reminder and by runtime permission denial —
and with a single turn allowed:
const o = await gv({
promptMessages: [...h, Pe({ content: f })],
cacheSafeParams: r, // reuses the previous main-loop request's params
canUseTool: async () => ({
behavior: "deny",
message: "Side questions cannot use tools",
decisionReason: { type: "other", reason: "side_question" }
}),
querySource: "side_question",
forkLabel: "side_question",
maxTurns: 1, // <- no turn remains after a denied tool call
skipCacheWrite: true,
skipTranscript: true, // <- why nothing lands in the .jsonl
...
});
The response extractor joins assistant text blocks first and returns immediately if the result
is non-empty. The tool_use branch — the one that produces the user-facing notice — is reachable
only when the joined text is empty:
function w(t) {
const r = t.flatMap(e => e.type === "assistant" ? e.message.content : []);
if (r.length > 0) {
const e = Nr(r, "\n").trim();
if (e) return { response: e, synthetic: false }; // <- preamble returned as "the answer"
const s = r.find(a => a.type === "tool_use");
if (s) return { response: `(The model tried to call ${"name" in s ? s.name : "a tool"} instead of answering directly. Try rephrasing or ask in the main conversation.)`, synthetic: true };
}
const n = t.find(m);
if (n) return { response: `(API error: ${n.error.formatted})`, synthetic: true };
return { response: null, synthetic: false };
}
So [text preamble, tool_use] → the tool is denied → maxTurns: 1 leaves no turn to write the
answer → w() sees non-empty text and returns the preamble with synthetic: false → no notice.
Because synthetic is false, the truncated exchange is then appended to the side-question history:
if (c && u && !p) c.append(t, u, d?.content); // c = session.btwHistory
and that history is replayed as prompt messages on the next side question:
const h = (a ?? c?.exchanges ?? []).flatMap(o => [
Pe({ content: o.question }),
bc({ content: o.fallbackNotice ? `⚠ ${o.fallbackNotice}\n${o.response}` : o.response })
]);
A truncated preamble therefore becomes few-shot precedent for every later /btw in the session,
which is a plausible mechanism for the within-session stickiness described above.
Claude Model
Opus
Is this a regression?
I don't know
Claude Code Version
2.1.250 (Claude Code)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Other
Additional Information
On the regression question — please do not bisect. I subjectively remember /btw answering
normally in earlier months, but I compared the three builds I still have on disk and the relevant
code is unchanged:
| Build | maxTurns | canUseTool | extractor ordering | history append |
|---|---|---|---|---|
| 2.1.226 | 1 | deny | text-first, tool_use unreachable when text exists | yes |
| 2.1.239 | 1 | deny | identical | yes |
| 2.1.250 | 1 | deny | identical | yes |
The only difference I found across these three is where the side-question history lives: 2.1.226
reads it from a module-level singleton, while 2.1.239 and 2.1.250 readtoolUseContext.session.btwHistory. The truncation path itself is byte-identical.
So this is most likely not a code regression but a long-standing latent defect whose exposure
depends on how strongly the surrounding session context pushes the model toward tool use, which
would also explain the all-or-nothing-per-session pattern in property 2. I cannot supply a Last
Working Version for that reason, and I would not trust my own recollection over the byte
comparison above.
Environment note: I am on the Claude Code macOS desktop app, not a terminal emulator, so
"Other" was the only applicable choice in the Terminal dropdown. All reproductions above are from
the desktop app; the terminal CLI is untested.
Suggested fixes, in rough order of value:
- Allow the side-question turn to continue after a denied tool call so the model can answer
without tools (i.e. do not let maxTurns: 1 be consumed by a call that was never going to run).
- Always surface the tool-call notice when the turn ended on a denied call, even if partial text
was produced — the current text-first ordering makes the notice unreachable in the common case.
- Do not append a denied/truncated exchange to
btwHistory, or mark it synthetic so it is
excluded.
- Consider omitting tool definitions from the side-question request. Keeping them appears to be a
prompt-cache concern (cacheSafeParams), but it means inherited "call a tool first" directives
from CLAUDE.md or SessionStart hooks can dominate the side-question system-reminder.
Related but distinct existing issues: #65878 and #80595 both cover the missing-persistence half
(/btw exchanges never reaching the transcript). Both are closed, yet on 2.1.250 the behaviour is
unchanged — skipTranscript: true is still passed, and step 7 above still returns 0 — so
property 3 continues to block self-diagnosis of this bug. Neither issue covers the silent
truncation or the missing notice. Also related: #65411 (rendering), #85674 (logging feature
request), #65781 (Esc key).
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗