[Bug]: Claude startup steer overtakes the opening prompt; the model answers the clarification without its context#22531

opened20d agobyRr3xsean

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

Timing-dependent: it needs a cold Claude session start, and I haven't reproduced it outside a real session (see the end of this report). What happened:

  1. Leave a Claude thread idle long enough that its CLI process is released (about 77 min here).
  2. Send a message (A). T3 resumes the Claude session and offers A.
  3. Within a second or two of the run starting, Steer a short clarification (B), e.g. "in the icons *".

A deterministic adapter-level regression is attached below. Its tests fail on main @ 5d4c202e.

Expected behavior

A steer sent before the opening prompt has reached the model is answered after A, or together with it. T3 doesn't complete the run while A is still pending.

Actual behavior

The model answered B alone, saying it had never seen an earlier message. A then ran later as a separate native turn, and T3 had already completed the run on B's result. My re-send (C) then aborted A before it answered. In the T3 UI, A and B appear in the same run in the right order, so nothing suggested A hadn't reached the model.

Verified from the T3 provider log and the Claude transcript (timeline under "Logs"):

  • B was offered with priority: "now" 601 ms after A's offer. Claude had already reported command_lifecycle(A, started), but A had produced no model output yet.
  • B became the first user turn. Its result reported queued_turn_count: 0, and T3 completed the run on that result.
  • Claude then emitted command_lifecycle(A, started) a second time and ran A as the next turn, without T3 offering A again.

Inferred: the now command interrupted A during CLI startup, and Claude Code requeued A behind B. A contentless queue enqueue in the transcript at 18:29:40.508 fits this, but I can't prove that entry is A.

Cause in T3: steerTurn (apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts, around L9745-9790) always offers steers with priority: "now", and the only check is that T3's turn is the active one. Claude's started signal isn't a safe point to steer: here it arrived before the steer. The init echo isn't safe either, because it echoed A before B was answered. The first reliable sign that A has reached the model is a root stream_event correlated with A's UUID. Claude Code's behaviour (a second started with no queued/cancelled in between, and queued_turn_count: 0 while A was pending) is confusing too. The docs exclude system-generated queue entries from that count, though, so I'm not calling it a Claude Code bug.

Regression tests (patch below; it only adds tests to ClaudeAdapterV2.test.ts):

  • startup steer: is not offered as now before the opening prompt streams (init echo=false|true) is the proposed regression. After startTurn and A's queued/started lifecycle (optionally with an init that echoes A), steerTurn must not offer anything with priority: "now" before A's first correlated stream event. It doesn't depend on the fix: holding the steer, sending it next, or merging it into the opening prompt would all pass. It currently fails: expected [ { type: 'user', … } ] to have a length of +0 but got 1. It mocks skill discovery and uses a sync scheduler, so there are no sleeps.
  • startup steer: a second start of the opening prompt leaves its turn running is a diagnostic. After B's result (queued_turn_count: 0), a second started(A) leaves the provider turn completed instead of running. A prevention fix may make this sequence impossible, so I'd treat it as diagnostic, not an acceptance criterion.
1vp test run apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts -t "startup steer"
2# 3 failed | 342 skipped; the neighbouring "keeps a started prompt running until its result" still passes
Patch: regression tests only
diffdiff
1index 13852826..536a6bc7 100644
2--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
3+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
4@@ -93,6 +93,7 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"
5 import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
6 import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
7 import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts";
8+import * as ClaudeSkills from "../../provider/Drivers/ClaudeSkills.ts";
9 import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
10 import { ClaudeOrchestratorReplayHarness } from "./ClaudeAdapterV2.testkit.ts";
11 import { provideDeterministicTestRuntime } from "../testkit/DeterministicRuntime.ts";
12@@ -8778,6 +8779,205 @@ describe("ClaudeAdapterV2 background wake turns", () => {
13 ),
14 );
15
16+ it.effect.each([false, true])(
17+ "startup steer: is not offered as now before the opening prompt streams (init echo=%s)",
18+ (initEcho) => {
19+ const dispatcher = new Scheduler.MixedScheduler("sync").makeDispatcher();
20+ return Effect.scoped(
21+ Effect.gen(function* () {
22+ // Remove unrelated skill-discovery I/O so flush reaches the steer
23+ // offer or its intentional wait, without sleeps or yield polling.
24+ using _skills = vi
25+ .spyOn(ClaudeSkills, "discoverClaudeSkills")
26+ .mockReturnValue(Effect.succeed([]));
27+ const harness = yield* makeWakeHarness;
28+ const idAllocator = yield* IdAllocator.IdAllocatorV2;
29+ const attemptId = RunAttemptId.make("attempt-startup-steer-offer");
30+ const input = makeClaudeTestTurnInput({
31+ threadId: harness.threadId,
32+ providerThread: harness.providerThread,
33+ now: yield* DateTime.now,
34+ attemptId,
35+ text: "Show the effort level.",
36+ attachments: [],
37+ });
38+ yield* harness.runtime.startTurn(input);
39+ const promptUuid = harness.promptUuid(0);
40+ for (const state of ["queued", "started"]) {
41+ yield* harness.offerAndWait(
42+ claudeSdkFrame({
43+ type: "command_lifecycle",
44+ command_uuid: promptUuid,
45+ state,
46+ uuid: `opening-${state}`,
47+ session_id: WAKE_NATIVE_SESSION,
48+ }),
49+ );
50+ }
51+ if (initEcho) {
52+ yield* harness.offerAndWait(
53+ claudeSdkFrame({
54+ type: "system",
55+ subtype: "init",
56+ uuid: "opening-init",
57+ session_id: WAKE_NATIVE_SESSION,
58+ user_message_uuids: [promptUuid],
59+ }),
60+ );
61+ }
62+ const steering = yield* harness.runtime
63+ .steerTurn({
64+ threadId: harness.threadId,
65+ runId: input.runId,
66+ providerThread: harness.providerThread,
67+ providerTurnId: idAllocator.derive.providerTurn({
68+ driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
69+ nativeTurnId: `turn:${attemptId}`,
70+ }),
71+ message: {
72+ createdBy: "user",
73+ creationSource: "web",
74+ messageId: MessageId.make("startup-steer"),
75+ text: "In the icons.",
76+ attachments: [],
77+ },
78+ })
79+ .pipe(Effect.forkScoped({ startImmediately: true }));
80+ dispatcher.flush();
81+ const prematureNowOffers = harness.offeredMessages.filter(
82+ (message) => message.priority === "now",
83+ );
84+ // Release a waiting implementation before joining; next/merge may
85+ // already have returned. No particular implementation is required.
86+ yield* harness.offerAndWait(
87+ claudeSdkFrame({
88+ type: "stream_event",
89+ uuid: "opening-first-stream",
90+ session_id: WAKE_NATIVE_SESSION,
91+ parent_tool_use_id: null,
92+ user_message_uuid: promptUuid,
93+ user_message_uuids: [promptUuid],
94+ event: {
95+ type: "message_start",
96+ message: {
97+ id: "opening-model-message",
98+ type: "message",
99+ role: "assistant",
100+ model: "claude-sonnet-4-6",
101+ content: [],
102+ stop_reason: null,
103+ stop_sequence: null,
104+ usage: { input_tokens: 1, output_tokens: 0 },
105+ },
106+ },
107+ }),
108+ );
109+ yield* Fiber.join(steering);
110+ assert.lengthOf(prematureNowOffers, 0);
111+ }).pipe(
112+ Effect.provide(
113+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
114+ ),
115+ Effect.provideService(Scheduler.Scheduler, {
116+ executionMode: "sync",
117+ shouldYield: () => false,
118+ makeDispatcher: () => dispatcher,
119+ }),
120+ ),
121+ );
122+ },
123+ );
124+
125+ it.effect("startup steer: a second start of the opening prompt leaves its turn running", () => {
126+ const dispatcher = new Scheduler.MixedScheduler("sync").makeDispatcher();
127+ return Effect.scoped(
128+ Effect.gen(function* () {
129+ const harness = yield* makeWakeHarness;
130+ const idAllocator = yield* IdAllocator.IdAllocatorV2;
131+ const attemptId = RunAttemptId.make("attempt-startup-steer-restart");
132+ const input = makeClaudeTestTurnInput({
133+ threadId: harness.threadId,
134+ providerThread: harness.providerThread,
135+ now: yield* DateTime.now,
136+ attemptId,
137+ text: "Show the effort level.",
138+ attachments: [],
139+ });
140+ yield* harness.runtime.startTurn(input);
141+ const promptUuid = harness.promptUuid(0);
142+ const lifecycle = (state: string, uuid: string) =>
143+ claudeSdkFrame({
144+ type: "command_lifecycle",
145+ command_uuid: promptUuid,
146+ state,
147+ uuid,
148+ session_id: WAKE_NATIVE_SESSION,
149+ });
150+ const init = (uuid: string, echo: boolean) =>
151+ claudeSdkFrame({
152+ type: "system",
153+ subtype: "init",
154+ uuid,
155+ session_id: WAKE_NATIVE_SESSION,
156+ ...(echo ? { user_message_uuids: [promptUuid] } : {}),
157+ });
158+
159+ // The captured CLI 2.1.304 trace says "started" before the steer,
160+ // then echoes the opening UUID on init without answering that prompt.
161+ yield* harness.offerAndWait(lifecycle("queued", "opening-queued"));
162+ yield* harness.offerAndWait(lifecycle("started", "opening-started"));
163+ yield* harness.runtime.steerTurn({
164+ threadId: harness.threadId,
165+ runId: input.runId,
166+ providerThread: harness.providerThread,
167+ providerTurnId: idAllocator.derive.providerTurn({
168+ driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
169+ nativeTurnId: `turn:${attemptId}`,
170+ }),
171+ message: {
172+ createdBy: "user",
173+ creationSource: "web",
174+ messageId: MessageId.make("startup-steer"),
175+ text: "In the icons.",
176+ attachments: [],
177+ },
178+ });
179+ yield* harness.offerAndWait(init("opening-init", true));
180+ yield* harness.offerAndWait(init("steer-init", false));
181+ yield* harness.offerAndWait(
182+ makeAssistantTextFrame({
183+ uuid: "steer-answer",
184+ text: "What should change in the icons?",
185+ }),
186+ );
187+ yield* harness.offerAndWait(
188+ claudeSdkFrame({
189+ ...makeResultFrame({
190+ uuid: "steer-result",
191+ result: "What should change in the icons?",
192+ }),
193+ queued_turn_count: 0,
194+ }),
195+ );
196+ yield* harness.offerAndWait(lifecycle("started", "opening-restarted"));
197+ dispatcher.flush();
198+ const latest = harness.events.findLast((event) => event.type === "provider_turn.updated");
199+ assert.equal(latest?.type, "provider_turn.updated");
200+ if (latest?.type !== "provider_turn.updated") return;
201+ assert.equal(latest.providerTurn.status, "running");
202+ }).pipe(
203+ Effect.provide(
204+ Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
205+ ),
206+ Effect.provideService(Scheduler.Scheduler, {
207+ executionMode: "sync",
208+ shouldYield: () => false,
209+ makeDispatcher: () => dispatcher,
210+ }),
211+ ),
212+ );
213+ });
214+
215 it.effect("keeps a started prompt running until its result", () =>
216 Effect.scoped(
217 Effect.gen(function* () {

Not reproduced in isolation. A standalone Agent SDK probe (SDK 0.3.276, CLI 2.1.304 and 2.1.305, claude-haiku-5-5, no tools, no settings, fresh and resumed sessions) sent B 0 ms or 500 ms after started(A). In every run, B arrived before A's first stream, and before A's init in the 0 ms runs, but Claude cleanly cancelled A (aborted_streaming, cancelled(A)) and B's answer included A. So the reorder needs something a real session has and the minimal probe doesn't, perhaps the longer startup with MCP servers, skills and hooks, or the session's contents. I haven't narrowed it down.

Impact

Minor bug or occasional failure

Version or commit

Desktop nightly 0.1.1-nightly.20261020.3401; source read and tested at main @ 5d4c202e

Environment

Windows 11 x64, T3 Code Desktop (nightly), Claude Code 2.1.304 (sdk-ts), claude-opus-5-5

Logs or stack traces

shell
1# T3 provider log + Claude transcript, redacted. A = opening prompt, B = steer, C = re-send.
218:29:38.512 T3 -> query.open (resume)
318:29:38.513 T3 -> offer A (uuid A, no priority)
418:29:38.534 CLI -> command_lifecycle(A, queued)
518:29:38.550 CLI -> command_lifecycle(A, started)
618:29:39.114 T3 -> offer B, priority "now"
718:29:40.507 CLI -> system/init, user_message_uuids [A]
818:29:40.557 transcript: user turn 12 = B
918:29:51.448 CLI -> result(B) success, queued_turn_count 0 (T3 completes the run)
1018:29:51.481 CLI -> command_lifecycle(A, started) (again; T3 did not offer A again)
1118:29:51.533 transcript: user turn 13 = A
1218:29:54.328 CLI -> stream_event message_start, user_message_uuid A
1318:29:59.581 T3 -> offer C, priority "now"
1418:29:59.590 CLI -> result(A) error_during_execution, terminal_reason aborted_streaming
1518:29:59.593 CLI -> command_lifecycle(A, cancelled)

Workaround

Use Queue rather than Steer right after sending a message. The clarification then waits for the first answer, but it can't overtake it. I haven't tested this through T3's Queue path.


Related: #21895 (now-priority steers cancel Claude tools; the fix in #21158 only changes agent steers) and #22423 (a steer counts as delivered once offered; there is no consumed state). Neither covers this startup reorder.

I'm happy to open a focused PR once you've chosen the behaviour: hold the steer until A streams, send it next, or merge it into A.

Investigated with Claude Opus 5.5 (Claude Code) and GPT-6-Astra (Codex) in T3 Code; the regression tests were written by GPT-6-Astra.

Note

Grok responding on behalf of Julius.

Thanks for the timeline. The T3 side matches current main. steerTurn always offers the steer with priority: "now", and the only checks are that the query is live and that T3's turn is still active (apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts:9745-9790). Nothing waits for the opening prompt to reach the model first, so right after a cold resume a steer can be offered while that prompt is still starting up.

Steering with now is intended for interrupting work already in progress. It isn't meant to jump ahead of the prompt the run was opened for. Claude putting your first message back in its queue behind the steer is inferred from the transcript rather than certain. Either way, T3 then completed the run on the steer's result, which left the first message to run outside it.

This is separate from #22510, which is a wake continuation losing its result to a queued user run. It's also separate from #22423, which is about input around a provider crash, and from the Bash and tool cancellation cases in #15720, #15708 and #21895.

Accepting this as a bug.