[BUG] Mid-turn task-summary classifier keeps the previous conversation's `latestAsk` across /clear (and image-bearing prompts never refresh it) — activity line describes a cleared conversation

Status Open
Reported on v2.1.267
Maintainer reply None cached
Activity 0 comments · opened Sep 10, 2026

Environment

  • Claude Code 2.1.267
  • Windows 11, interactive REPL in the VS Code integrated terminal, Opus 5 (1M context), high effort
  • Remote Control enabled — this is what activates the summary/headline surfaces of the mid-turn classifier

Summary

After /clear, the mid-turn task-summary classifier keeps the previous conversation's latestAsk and writes an activity line about it, on top of the new conversation's tool calls. The line is stamped with a fresh at: Date.now(), so the renderer's staleness guard does not suppress it, and the same phrase is mirrored to Remote Control as external_metadata.task_summary — the phone shows the wrong topic too.

Observed, five minutes into a turn that had nothing to do with the previous conversation:

● <a 4-word summary of the previous conversation's last ask>
  ⎿  $ cd <project> && sed -n '<range>' <path>

The previous conversation had been cleared about three minutes earlier. The current one was unrelated and shared no vocabulary with it, and the sed call is genuinely part of the current work — only the headline is stale.

Prompt text is elided throughout; timestamps, message shapes and code excerpts are unmodified.

Two independent defects combine here. Either one alone is a bug; together they produce a headline that describes a conversation the user already cleared.

Root cause

Symbol names from the shipped binary at ~/.local/share/claude/versions/2.1.267.

1. The classifier job state is process-scoped and no conversation reset clears it

var ICt;
function NU(e){ if(ICt===void 0) ICt = QC?.().createClassifierJobState(e) ?? null; return ICt }

and the second construction site, keyed by the host object, which also outlives a conversation:

class zc{ state = void 0 }
var By = new z(()=>new zc);
function Hc(w){ let I = By.of(w.session.host); if(I.state===void 0) I.state = Di?.().createClassifierJobState(w) ?? null; return I.state }

createClassifierJobState seeds the conversation-scoped fields:

function gn(e={}){ return { ..., prevState:"", prevStateSince:Date.now(), accumulatedOutputs:{},
  lastClassifyAt:0, capturedIntent:"", ..., latestAsk:"", kicked:!1, lastBridgeNeeds:void 0,
  lastMsgCount:0, ..., lastEmittedDetail:"", lastMidturnLlmAt:0, lastMidturnLlmDetail:"",
  midturnLlmIntervalMs:0, midturnLlmEpoch:0, lastResult:null } }

The module exports no reset for it (export{... createClassifierJobState, ..., markApiFailure, markTurnAborted, markTurnActive, ...}); markTurnAborted clears only turn-scoped fields (lastEmittedDetail, lastMidturnLlmDetail, the epoch), never latestAsk, capturedIntent, prevState or lastMsgCount.

clearConversation resets a great deal — tracked file state, task registry, nested memory paths, session env vars, memory selector, file history — and emits conversation_reset, but it never calls into the classifier:

async function*Nln({session:o, setMessages:m, readFileState:M, ...}){
  ...
  if(M.clear(), r5.of(o.host).reset(), v) for(let e of Object.keys(v)) delete v[e];
  ...
  if(Ae()||hc()&&R8()) lc({type:"conversation_reset", new_conversation_id:P}), b("bridge_conversation_reset");

conversation_reset does reach sessionState.notifyConversationReset(), but the only subscriber wired to onConversationReset is the autocompact-state notifier. Nothing resets the classifier state, and taskSummary is absent from the app-state reset table used by /clear:

var r={bagelActive:void 0, ..., activeGoal:void 0, ..., artifactReadVersions:S, artifactReadObservers:l}, _=Object.keys(r);

So after /clear, latestAsk still holds the last ask of the conversation that was just discarded.

2. findLatestRealUserAsk only accepts string content, so an image-bearing prompt never refreshes latestAsk

function Sn(e){
  let n = e.findLast(r => r.type==="user" && !r.isMeta
        && typeof r.message.content === "string"
        && !_M(r.message.content) && !GEe(r.message.content, ()=>!0));
  return n?.type==="user" && typeof n.message.content==="string" ? n.message.content : void 0
}
function kn(e,n){ if(!n) return; e.latestAsk = fs(Xr(me(n)), 300) }

A prompt with a pasted screenshot has message.content as an array ([{type:"image"},{type:"text"}]), so findLatestRealUserAsk returns undefined, the call site skips captureLatestAsk, and the stale value stands:

let Jc = QC().findLatestRealUserAsk(Ar);
if(Jc) QC().captureLatestAsk(ds, Jc);

captureIntent has the same shape of problem in the other direction — it is write-once for the process (if(e.capturedIntent||!n) return e.capturedIntent), so it also never follows a /clear.

3. Why it reaches the screen

Mid-turn, the ask is often the only real signal — the same mechanism as #87533. Early in a turn there is no assistant prose yet, so the classifier prompt is a tool-name tally plus User's most recent ask: "<previous conversation's ask>", and the model writes the line around that ask:

p = { state:"working", tempo:"active", detail: e.lastMidturnLlmDetail || ot(s, e.describeToolUse), needs:void 0, output:{}, source:"midturn" };
...
let x = await Mt(Ce(ye,{prev:"working", latestAsk:e.latestAsk, toolSummary:_e, minsInState:..., engine:d, surfaces:c, credentials:e.credentials}), $e, "midturn classifier timed out")...
if(x?.source==="llm" && x.detail && e.midturnLlmEpoch===xe) e.lastMidturnLlmDetail = x.detail, p = {...p, detail:x.detail}

The emit site stamps the current time and publishes to both surfaces:

ds.onClassified=(Su,cf)=>{
  if(!cf || !(ho||$a) || Su.detail===ds.lastEmittedDetail) return;
  if(ds.lastEmittedDetail=Su.detail, ho) Vt.sessionState?.notifyMetadataChanged({task_summary:Su.detail});
  if($a){ let Vd = Su.detail ? {text:Su.detail, at:Date.now()} : null;
    Vt.setAppState(r_ => r_.taskSummary?.text===Vd?.text ? r_ : {...r_, taskSummary:Vd}) }
}

The TUI's only staleness guard is a timestamp comparison against the message being rendered:

vf = (bb) => Ue && !bb.viewingAgentTaskId && bb.taskSummary!==null && !(bb.taskSummary.at < pN) ? bb.taskSummary.text : null   // pN = Date.parse(se.timestamp)

Because at is the emit time, not the time of the content the phrase describes, a phrase derived from a discarded conversation passes the guard on every message of the new one. There is no conversation identity on taskSummary, so nothing else can catch it.

The summary surface is what Remote Control consumes (external_metadata.task_summary, described in the SDK types as "Mid-turn progress line from the debounced classifier"), which is why this is only visible with RC on, and why the wrong topic also reaches the phone.

Repro

  1. Enable Remote Control.
  2. Ask any question in a text-only prompt, on a topic with distinctive vocabulary. Let the turn finish.
  3. /clear.
  4. Submit an unrelated prompt with an image attached (pasted screenshot), one that will run for a few minutes of tool calls.
  5. Watch the ● activity line above the running tool group: it describes step 2's question, not step 4's.

The image in step 4 matters. A text-only prompt refreshes latestAsk on the first classification and masks defect 1; with an image, latestAsk is never overwritten and the previous conversation's ask survives indefinitely.

Evidence from the local transcripts

  • previous session <session A>, last user message with string content, 03:21:09Z: a one-sentence question, string content, no attachments — the ask the stale headline summarizes
  • current session <session B>: /clear at 03:24:39Z; the only real prompt, 03:27:20Z, has content: [image, text]; the rendered Bash call at 03:32:08Z is isSidechain:false and appears only in this session's JSONL
  • both sessions carry the same bridgeSessionId (/clear keeps the process and the bridge session), so the process-scoped classifier state carried straight across

The headline is a faithful summary of the previous session's last ask, rendered over the current session's work.

Suggested fix

  1. Reset the classifier job state on conversation reset. The hook already exists — sessionState.notifyConversationReset() fires on /clear; subscribe the classifier to it and clear at minimum latestAsk, capturedIntent, prevState, prevStateSince, lastMsgCount, lastResult and accumulatedOutputs, alongside the turn-scoped fields markTurnAborted already handles. Clearing appState.taskSummary in clearConversation, and the mirrored task_summary metadata, would make the reset complete.
  2. Make findLatestRealUserAsk handle array content by joining its text blocks, so multimodal prompts refresh the ask like text prompts do. As written, a conversation whose prompts all carry images never updates latestAsk at all — even without /clear.
  3. Consider carrying the conversation id on taskSummary and dropping it on mismatch, so a stale phrase cannot outlive the conversation it came from regardless of timestamps.

Related: #87533 (same classifier: latestAsk hard-truncated at 300 chars with no marker, and the classifier's complaint about that rendered as the activity line).

View original on GitHub ↗