[Bug]: A Claude steer waiting behind a running command shows as delivered and the thread goes silent#23948

opened8d agobyDderektrimm

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Set Settings > General > Follow-up behavior to Steer.
  2. In a Claude thread, have the agent run a foreground Bash command that takes a few minutes.
  3. While it runs, send a follow-up. It shows with the Steer marker ("Steered the active turn").
  4. Watch the thread until the command returns.

Expected behavior

While a steer has not been read by the agent, the thread says so: for example, the steer marker reads "Waiting for the agent to read this" (or similar) until Claude acknowledges the message, and the running command keeps showing that it is alive. The user can tell "my message is waiting behind a command" apart from "the session is hung".

Actual behavior

The steer is marked as steered as soon as the server offers it, and from then on the thread shows nothing at all until the command returns or the user presses Stop. It reads as a frozen session. In my case the steer waited 300 s, I pressed Stop, and the steer was then dropped (#15708).

Two things combine to make this silent:

  1. No delivery state. ClaudeAdapterV2.steerTurn offers the steer with priority: "now" and no uuid, so no command_lifecycle event can tie it to Claude reading it. The web marker (UserMessageIntentMarker, "Steered the active turn") is static.
  2. Claude stops reporting the running command. Claude Code sends a tool_progress heartbeat for a foreground Bash call every 30 s. After a priority: "now" offer it stops sending them for the rest of the call, so nothing reaches the thread during the wait. This was also noted in a comment on #15720. Measured below.

This is separate from #15720 (the steer should interrupt the command; #23892) and #15708 (Stop drops the unread steer; #23807 adds a notice after Stop). #23892 makes the steer interrupt a root Bash command, but by its own description it still offers the steer without interrupting when a question or approval is pending or the interrupt is not answered within five seconds. #23807 adds steer UUIDs and acknowledgment tracking but only reports after Stop. Exposing that same acknowledgment state while the turn is running would cover the wait itself.

Impact

Major degradation or frequent failure

Version or commit

T3 Code desktop 0.1.1; ClaudeAdapterV2.steerTurn and UserMessageIntentMarker unchanged on main @ 71a283aed2

Environment

Linux x64 desktop app, Claude Code 2.1.308, claude-opus-5-5 (thread) and claude-haiku-5-5 (probe), @anthropic-ai/claude-agent-sdk 0.3.276

Logs or stack traces

Provider log of the real thread (decoded, in = CLI to server):

105:46:29.456 in assistant tool_use Bash
205:46:32.469 in system task_started local_bash is_backgrounded=false
305:46:59.466 in tool_progress Bash elapsed=30
405:47:29.466 in tool_progress Bash elapsed=60
505:47:56.783 out prompt.offer priority=now uuid=null <- steer 1
6 (no incoming frames for 4 m 47 s: no tool_progress at 90, 120, ... 270)
705:52:43.313 out prompt.offer priority=now uuid=null <- steer 2
805:52:55.539 out query.interrupt
905:52:55.539 out query.close <- Stop; both steers dropped (#15708)

Agent SDK probe (heartbeat-probe.mjs below; the agent runs curl against a local gate that answers only when released), CLI 2.1.308:

ArmCommand heldSteer offeredtool_progress heartbeats
control, no steer100 snone30, 60, 90
priority: "now" offer (current steerTurn)107 sat 35 s30 before the steer; none after it

In the steered arm the command ran another 70 s after the steer with no frames from the CLI; the steer was answered 71.5 s after it was offered, once the gate opened.

heartbeat-probe.mjs (run with STEER_AFTER_MS=35000 RELEASE_AFTER_STEER_MS=70000 node heartbeat-probe.mjs offer-now; control: CONTROL_RELEASE_MS=100000 node heartbeat-probe.mjs control)
js
1// Measures whether a Claude steer reaches the agent while a foreground Bash
2// command is running, the way T3 Code's ClaudeAdapterV2 delivers it.
3//
4// node heartbeat-probe.mjs <arm> [model]
5//
6// Env: STEER_AFTER_MS, RELEASE_AFTER_STEER_MS, CONTROL_RELEASE_MS, CLAUDE_BIN.
7// Logs Claude's tool_progress heartbeats for the running command.
8//
9// Arms:
10// control no steer; the gate opens 8 s after the command starts
11// offer-now T3 0.1.1 / main: offer the steer with priority "now"
12// interrupt-offer PR #23892: query.interrupt() (no close), then offer
13//
14// The agent runs `curl` against a local gate that answers only when released,
15// so the command stays in the foreground for as long as the probe wants.
16import { query } from "@anthropic-ai/claude-agent-sdk";
17import http from "node:http";
18import { mkdtempSync } from "node:fs";
19import { tmpdir } from "node:os";
20import path from "node:path";
21
22const arm = process.argv[2];
23const model = process.argv[3] ?? "claude-haiku-5-5";
24if (!["control", "offer-now", "interrupt-offer"].includes(arm)) {
25 console.error("usage: node heartbeat-probe.mjs control|offer-now|interrupt-offer [model]");
26 process.exit(2);
27}
28
29const STEER_AFTER_MS = Number(process.env.STEER_AFTER_MS ?? 4_000); // after the command reaches the gate
30const RELEASE_AFTER_STEER_MS = Number(process.env.RELEASE_AFTER_STEER_MS ?? 60_000); // a steer that waits shows as ~60 s
31const CONTROL_RELEASE_MS = Number(process.env.CONTROL_RELEASE_MS ?? 8_000);
32const HARD_TIMEOUT_MS = 240_000;
33
34const t0 = Date.now();
35const at = () => ((Date.now() - t0) / 1000).toFixed(2);
36const log = (...args) => console.log(`${at().padStart(7)}s`, ...args);
37
38// Gate: holds GET /hold until released.
39let releaseGate = () => {};
40const gateOpened = new Promise((resolve) => (releaseGate = resolve));
41let gateHit = null;
42const gateHitPromise = new Promise((resolve) => (gateHit = resolve));
43const server = http.createServer(async (req, res) => {
44 if (req.url !== "/hold") return res.writeHead(404).end();
45 gateHit();
46 await gateOpened;
47 res.writeHead(200).end("GATE_OPEN\n");
48});
49await new Promise((r) => server.listen(0, "127.0.0.1", r));
50const port = server.address().port;
51
52// Streaming input, like the T3 adapter's prompt queue.
53const pending = [];
54let wake = null;
55let inputClosed = false;
56const push = (msg) => {
57 pending.push(msg);
58 wake?.();
59};
60async function* input() {
61 while (true) {
62 while (pending.length) yield pending.shift();
63 if (inputClosed) return;
64 await new Promise((r) => (wake = r));
65 wake = null;
66 }
67}
68const userMessage = (text, priority) => ({
69 type: "user",
70 message: { role: "user", content: [{ type: "text", text }] },
71 parent_tool_use_id: null,
72 ...(priority ? { priority } : {}),
73});
74
75const cwd = mkdtempSync(path.join(process.env.TMPDIR ?? tmpdir(), "steer-probe-"));
76push(
77 userMessage(
78 `Use the Bash tool to run exactly this command in the foreground (not in the background, timeout 600000): curl -s http://127.0.0.1:${port}/hold\n` +
79 "It waits on a local test gate. When it returns, reply with its output and nothing else.",
80 ),
81);
82
83const q = query({
84 prompt: input(),
85 options: {
86 model,
87 cwd,
88 // Uses the CLI's existing login. Set CLAUDE_BIN to test a specific CLI build.
89 ...(process.env.CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.CLAUDE_BIN } : {}),
90 settingSources: [],
91 permissionMode: "bypassPermissions",
92 allowDangerouslySkipPermissions: true,
93 },
94});
95
96const STEER_TEXT = "Change of plan: stop waiting for that command and reply with exactly STEERED_NOW.";
97const result = { arm, model, cliVersion: null, sessionIds: new Set() };
98let steerAt = null;
99
100gateHitPromise.then(async () => {
101 log("gate: command is running (curl reached the gate)");
102 result.commandRunningAt = Date.now();
103 if (arm === "control") {
104 setTimeout(() => {
105 log("gate: released (control)");
106 releaseGate();
107 }, CONTROL_RELEASE_MS);
108 return;
109 }
110 await new Promise((r) => setTimeout(r, STEER_AFTER_MS));
111 steerAt = Date.now();
112 if (arm === "interrupt-offer") {
113 const answer = await q.interrupt().catch((e) => `error: ${e.message}`);
114 log("query.interrupt() answered", JSON.stringify(answer ?? null));
115 }
116 push(userMessage(STEER_TEXT, "now"));
117 log(`steer offered with priority "now"`);
118 setTimeout(() => {
119 log(`gate: released (${RELEASE_AFTER_STEER_MS / 1000} s after the steer)`);
120 releaseGate();
121 }, RELEASE_AFTER_STEER_MS);
122});
123
124const hard = setTimeout(() => {
125 log("HARD TIMEOUT");
126 finish(1);
127}, HARD_TIMEOUT_MS);
128
129let finished = false;
130function finish(code) {
131 if (finished) return;
132 finished = true;
133 clearTimeout(hard);
134 releaseGate();
135 inputClosed = true;
136 wake?.();
137 const s = (ms) => (ms == null ? null : +((ms - (steerAt ?? 0)) / 1000).toFixed(2));
138 const summary = {
139 arm,
140 model,
141 cliVersion: result.cliVersion,
142 sameSession: result.sessionIds.size === 1,
143 toolResult: result.toolResult ?? null,
144 toolResultSecondsAfterSteer: steerAt ? s(result.toolResultAt) : null,
145 steerAnsweredSecondsAfterSteer: steerAt ? s(result.answerAt) : null,
146 finalText: result.finalText ?? null,
147 heartbeatsBeforeSteer: (result.heartbeats ?? []).filter((h) => !steerAt || h.at < steerAt).map((h) => h.elapsed),
148 heartbeatsAfterSteer: (result.heartbeats ?? []).filter((h) => steerAt && h.at >= steerAt).map((h) => h.elapsed),
149 };
150 console.log("SUMMARY " + JSON.stringify(summary));
151 server.close();
152 setTimeout(() => process.exit(code), 500);
153}
154
155for await (const m of q) {
156 if (m.session_id) result.sessionIds.add(m.session_id);
157 if (m.type === "system" && m.subtype === "init") {
158 result.cliVersion = m.claude_code_version ?? null;
159 log("init", `cli=${result.cliVersion}`, `model=${m.model}`);
160 } else if (m.type === "system" && m.subtype === "task_started") {
161 log("task_started", m.task_type ?? "", `backgrounded=${m.is_backgrounded}`);
162 } else if (m.type === "tool_progress") {
163 log("tool_progress", m.tool_name, `elapsed=${m.elapsed_time_seconds}`);
164 (result.heartbeats ??= []).push({ at: Date.now(), elapsed: m.elapsed_time_seconds });
165 } else if (m.type === "assistant") {
166 for (const b of m.message.content) {
167 if (b.type === "tool_use") log("assistant tool_use", b.name, JSON.stringify(b.input).slice(0, 90));
168 if (b.type === "text" && b.text.trim()) {
169 log("assistant text", JSON.stringify(b.text.slice(0, 120)));
170 result.finalText = b.text.trim();
171 if (b.text.includes("STEERED_NOW") && !result.answerAt) result.answerAt = Date.now();
172 }
173 }
174 } else if (m.type === "user" && Array.isArray(m.message?.content)) {
175 for (const b of m.message.content) {
176 if (b.type === "tool_result") {
177 const text = typeof b.content === "string" ? b.content : JSON.stringify(b.content);
178 result.toolResult ??= text.slice(0, 100);
179 result.toolResultAt ??= Date.now();
180 log("tool_result", JSON.stringify(text.slice(0, 100)));
181 }
182 }
183 } else if (m.type === "result") {
184 log("result", m.subtype, m.terminal_reason ?? "");
185 const done =
186 arm === "control" ? m.subtype === "success" : result.answerAt != null && m.subtype === "success";
187 if (done) finish(0);
188 }
189}
190finish(result.answerAt || arm === "control" ? 0 : 1);

Workaround

Press Stop and send the message again.