[BUG] Mid-turn task-summary classifier gets the user's ask hard-truncated at 300 chars with no marker, and renders its complaint about that as the on-screen activity line

Status Open
Reported on v2.1.234
Maintainer reply None cached
Activity 1 comment · opened Aug 18, 2026

Environment

  • Claude Code 2.1.234 (GIT_SHA 7215ba60b06dff03b3e75825084c7038a013d0b0, BUILD_TIME 2026-08-17T01:20:38Z)
  • Windows 11, interactive REPL in the VS Code integrated terminal, Opus 5 (1M context), high effort
  • Remote Control enabled — this is what activates the code path (see "When this path is live")

Summary

The mid-turn task-summary classifier receives the user's most recent ask hard-cut at 300 characters, with no ellipsis and no truncation marker, under a prompt label that presents it as the complete ask. When the ask is longer than that, the classifier frequently stops classifying and reports the defect instead. That report is written to detail, which the TUI renders verbatim as the activity line, replacing the normal tool tally — and it sticks on screen for minutes.

Observed line, mid-turn, on the header of the active tool group:

● User message cut off mid-sentence; cannot assess task
  ⎿  $ cd <dir> && rg -o ".{250}dataTransfer.{250}" index.js | head -12

The user's message was not cut off. It was ~800 characters and fully delivered to the main model, which was working the task correctly at the time.

Root cause

All symbol names below are from the shipped binary at ~/.local/share/claude/versions/2.1.234.

1. The ask is truncated without a marker.

function aMv(e, t) { if (!t) return; e.latestAsk = ZS(pp(b1a(t)), 300) }

function ZS(e, t) {
  if (e.length <= t) return e;
  let r = [];
  for (let n of e) { if (r.length >= t) break; r.push(n) }
  return r.join("")           // hard cut — no ellipsis, no marker
}

A sibling helper in the same file already does this correctly and is used for other detail fields:

function vv(e, t) { ...; return e.slice(0, r) + "…" }

2. The prompt presents the truncated string as complete.

function m7p(e) {
  let { tail: t, prev: r, latestAsk: n, toolSummary: o, minsInState: i } = e;
  return `Current state: ${r} (for ${i}m)
Tool calls so far: ${o || "none"}${n ? `
User's most recent ask: "${n}"` : ""}
Assistant message tail (last ${t.length} chars):
${t}`
}

Note the asymmetry: the assistant tail is explicitly labelled (last N chars), so the classifier knows that one is a fragment. The ask carries no such label, so a mid-word cut reads as a genuinely malformed user message.

3. Mid-turn, the truncated ask is often the only real signal.

The mid-turn call passes only the assistant text accumulated so far this turn:

let q = pp(o.slice(N).filter(Z => !Z.isApiErrorMessage).map(Z => Cte(Z) || h1a(Z)).filter(Boolean).join("\n")),
    se = npi(o);
if (q || se) { ... g1a(q, { prev: "working", latestAsk: e.latestAsk, toolSummary: se, ... }) }

Early in a turn — thinking plus a first tool call, no prose yet — q is empty or near-empty. The classifier then has a tool-name list and a sentence chopped mid-word, and comments on the input rather than the work.

4. The classifier's job is not narration. System prompt f7p opens:

A user kicked off a Claude Code agent to do a coding task and walked away. Read the tail of what the agent just said and decide which of four states it's in, so the system knows whether to notify the user.

It is a notification-state classifier (done / working / blocked / failed). Its free-text detail field is reused as the live activity headline, so any meta-commentary it produces lands directly in the transcript.

5. The bad phrase is sticky.

f = { state: "working", tempo: "active", detail: e.lastMidturnLlmDetail || gMv(o, e.describeToolUse), ... }

lastMidturnLlmDetail is reused on every subsequent mid-turn refresh, and the LLM re-call interval starts at midTurnLlmDebounceMs ?? 60000 and doubles up to a rMv = 240000 ms cap. A single bad phrase persists for minutes. In the observed case it was still on screen at 3m 06s.

6. Rendering. detail reaches the TUI as appState.taskSummary.text and wins over the normal tool tally unconditionally:

ke ? Xa.jsx(_, { children: ij(ke) }, "task-summary") : Le

so the informative line ("Read 3 files, ran 2 commands…") is replaced by the classifier's complaint.

When this path is live

J7p maps the session to surfaces, Q7p maps surfaces to sinks, eXp picks the engine from the sinks:

| Session | Surfaces | Sinks | Engine |
|---|---|---|---|
| Plain REPL, no Remote Control | repl | headline | heuristic (last line of assistant text) — no model call |
| REPL + Remote Control | repl, bridge | headline, summary | env CLAUDE_CODE_CLASSIFIER_SUMMARY, else gate tengu_classifier_summary_llm_emit |
| REPL + watched (CAn() heartbeat fresh) | repl, watched | headline, state | llm, unconditionally |

Enabling Remote Control calls qFe(true), so cx() (replBridgeActive) is true and the bridge surface is added, which contributes the summary sink. That sink is what makes the LLM engine reachable at all — eXp short-circuits to heuristic on !e.has("summary"), so a REPL without Remote Control never makes this call.

Note the split: the summary sink is the one that gates the engine and ships detail to the remote client, but it is the headline sink (from repl) that renders detail into the local terminal transcript. So turning on Remote Control changes what is printed in the local terminal.

CLAUDE_CODE_CLASSIFIER_SUMMARY=0 restores the heuristic engine for the Remote Control case. It has no effect once the session is also watched, because has("state") → "llm" short-circuits ahead of it. There is no user-facing setting; tengu_classifier_summary_kill and tengu_classifier_disabled_surfaces are server-side gates.

Reproduction

  1. Enable Remote Control on an interactive terminal session (this adds the summary sink, which enables the LLM engine).
  2. Send a prompt longer than 300 characters whose 300-char prefix ends mid-word or mid-sentence.
  3. Let the turn run past the mid-turn classifier debounce (default 60s) with tool calls but no assistant prose yet.
  4. Watch the header of the active tool group.

Expected: an activity phrase describing the work, or the normal tool tally.
Actual: a phrase asserting the user's message is malformed — e.g. User message cut off mid-sentence; cannot assess task — held for minutes.

Impact

  • The transcript states something false about the user's own input, which reads as a delivery bug in Claude Code.
  • It displaces the genuinely useful activity line for the duration.
  • The same detail drives the four-state classification behind phone notifications, so a classifier that has bailed out on parsing its input is also not reliably deciding blocked vs working vs done.
  • The damage is not confined to the activity line. AMv maps the same classifier response into {status_category, status_detail, needs_action} for post_turn_summary, with status_detail set to this same detail string — so every surface fed by the classifier (recap, remote notification text) carries the same corrupted phrase.

Proposed fix

Primary, one line: mark the cut in aMv, using the helper already present in the file.

- e.latestAsk = ZS(pp(b1a(t)), 300)
+ e.latestAsk = vv(pp(b1a(t)), 300)

vv appends , which is enough for the classifier to read the string as an excerpt rather than a defect.

Worth considering alongside it:

  • Raise the 300-char cap. It is very tight next to the 2000-char assistant tail, and the ask is the classifier's main grounding for what the turn is even about.
  • Label the field the way the tail is labelled — User's most recent ask (first 300 chars): "…" — so the prompt is self-describing.
  • Instruct f7p that both inputs may be excerpts and that meta-commentary about input quality is never a valid detail; fall back to the heuristic engine when the model returns one.

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗