Repository navigation
Description
On Claude, delegated-task and command notices that arrive while the parent's turn is running are queued until that turn ends instead of reaching the running turn, as they do on Codex.
Follows #36106, which was closed as not reproducible because no worker had Claude OAuth. This still happens on current main at eb14ddb, and the repro below needs no provider login.
The test cited when this was closed (SteeringCompletion.integration.test.ts, "interrupting native steering") passes because it asserts this behaviour: with activeSteeringInterruptsTools: true, the delegated-task notice is delivered only after the turn ends. That is the bug, and Claude declares that flag (ClaudeAdapterV2.ts:528 on eb14ddb). mselcukozturk's live Claude timeline in #36106 shows the same thing: the child failed 4 s after it started, and the parent learned of it 3 min 23 s later, when its own turn ended.
The repro below runs the same parent turn and delegated-task completion through Orchestrator.dispatchMessage with a fake adapter. The only thing that changes between the two cases is the declared capabilities: CodexProviderCapabilitiesV2 or ClaudeProviderCapabilitiesV2. Save it as apps/server/src/orchestration-v2/ClaudeNoticeSteering.repro.test.ts, then:
cd apps/server
vp test run src/orchestration-v2/ClaudeNoticeSteering.repro.test.ts✓ codex: a delegated-task completion steers into the running parent turn
× claude: a delegated-task completion steers into the running parent turn
AssertionError: expected { steeredIntoRunningTurn: +0, …(1) } to deeply equal { steeredIntoRunningTurn: 1, …(1) }
{
- "queuedRuns": 0,
- "steeredIntoRunningTurn": 1,
+ "queuedRuns": 1,
+ "steeredIntoRunningTurn": 0,
}
Tests 1 failed | 1 passed (2)
On Codex the notice goes into the running turn. On Claude it becomes a queued run that starts only after the parent's turn ends.
Flipping the flag alone isn't safe while Claude steers still cancel MCP tools (#21895). #35462 fixes that first.
ClaudeNoticeSteering.repro.test.ts
import { assert, it } from "@effect/vitest";
import {
CommandId,
EventId,
MessageId,
NodeId,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
ProviderThreadId,
ProviderTurnId,
ThreadId,
type OrchestrationV2DomainEvent,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import { ClaudeProviderCapabilitiesV2 } from "./Adapters/ClaudeAdapterV2.ts";
import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
import * as EffectWorker from "./EffectWorker.ts";
import * as EventSink from "./EventSink.ts";
import * as Orchestrator from "./Orchestrator.ts";
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
// #36106: a delegated-task notice should reach the parent's running turn on every provider
// that supports active steering. Fake adapter; only the declared capabilities differ.
it.effect.each([
{ provider: "codex", capabilities: CodexProviderCapabilitiesV2 },
{ provider: "claude", capabilities: ClaudeProviderCapabilitiesV2 },
] as const)(
"$provider: a delegated-task completion steers into the running parent turn",
({ provider, capabilities }) =>
Effect.gen(function* () {
const driver = ProviderDriverKind.make(provider);
const instanceId = ProviderInstanceId.make(provider);
const modelSelection = { instanceId, model: "test-model" };
const cwd = yield* checkpointWorkspace(`claude-notice-steering-${provider}`);
const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>();
const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = [];
const steered: ProviderAdapter.ProviderAdapterV2SteerInput[] = [];
const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = {
instanceId,
driver,
getCapabilities: () => Effect.succeed(capabilities),
planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }),
openSession: (input) =>
Effect.gen(function* () {
const now = yield* DateTime.now;
return {
instanceId,
driver,
providerSessionId: input.providerSessionId,
providerSession: {
id: input.providerSessionId,
driver,
providerInstanceId: instanceId,
status: "ready",
cwd,
model: modelSelection.model,
capabilities,
createdAt: now,
updatedAt: now,
lastError: null,
},
events: Stream.fromQueue(events),
ensureThread: ({ threadId }) =>
Effect.succeed({
id: ProviderThreadId.make(`provider-thread:${threadId}`),
driver,
providerInstanceId: instanceId,
providerSessionId: input.providerSessionId,
appThreadId: threadId,
ownerNodeId: null,
nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" },
nativeConversationHeadRef: null,
status: "idle",
firstRunOrdinal: null,
lastRunOrdinal: null,
handoffIds: [],
forkedFrom: null,
createdAt: now,
updatedAt: now,
}),
resumeThread: ({ providerThread }) => Effect.succeed(providerThread),
startTurn: (turn) =>
Effect.gen(function* () {
started.push(turn);
yield* Queue.offer(events, {
type: "provider_turn.updated",
driver,
providerTurn: {
id: ProviderTurnId.make(`provider-turn:${turn.attemptId}`),
providerThreadId: turn.providerThread.id,
nodeId: turn.rootNodeId,
runAttemptId: turn.attemptId,
nativeTurnRef: {
driver,
nativeId: `native:${turn.attemptId}`,
strength: "strong",
},
ordinal: turn.providerTurnOrdinal,
status: "running",
startedAt: now,
completedAt: null,
},
});
}),
steerTurn: (turn) => Effect.sync(() => void steered.push(turn)),
interruptTurn: () => Effect.void,
respondToRuntimeRequest: () => Effect.void,
readThreadSnapshot: () => Effect.die("unused"),
rollbackThread: () => Effect.die("unused"),
forkThread: () => Effect.die("unused"),
};
}),
};
yield* Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
const threadId = ThreadId.make(`thread:notice-${provider}`);
const watch = (predicate: (event: OrchestrationV2DomainEvent) => boolean) =>
orchestrator.streamDomainEvents.pipe(
Stream.filter(predicate),
Stream.take(1),
Stream.runDrain,
Effect.forkScoped,
);
yield* orchestrator.dispatch({
type: "thread.create",
commandId: CommandId.make("create"),
threadId,
projectId: ProjectId.make("project:notice"),
title: "Parent",
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: cwd,
createdBy: "user",
creationSource: "web",
});
yield* orchestrator.dispatch({
type: "message.dispatch",
commandId: CommandId.make("first"),
threadId,
messageId: MessageId.make("message:first"),
text: "Delegate the review, then keep working.",
attachments: [],
dispatchMode: { type: "start_immediately" },
createdBy: "user",
creationSource: "web",
});
const running = yield* watch(
(event) => event.type === "provider-turn.updated" && event.payload.status === "running",
);
yield* worker.drain();
yield* Fiber.join(running);
const parent = started[0]!;
// The delegated child finishes while the parent turn is still running.
const messageId = MessageId.make("message:notice");
const taskId = NodeId.make("task:child");
const projection = yield* orchestrator.getThreadProjection(threadId);
const parentRun = projection.runs.find((run) => run.id === parent.runId)!;
const now = yield* DateTime.now;
yield* (yield* EventSink.EventSinkV2).write({
events: [
{
id: EventId.make("notice:cohort"),
type: "run.updated",
threadId,
runId: parent.runId,
occurredAt: now,
payload: {
...parentRun,
delegatedCompletion: {
disposition: "open",
nextGeneration: 2,
delivery: { generation: 1, messageId, taskIds: [taskId] },
},
},
},
{
id: EventId.make("notice:task"),
type: "subagent.updated",
threadId,
runId: parent.runId,
nodeId: taskId,
occurredAt: now,
payload: {
id: taskId,
threadId,
runId: parent.runId,
parentNodeId: parent.rootNodeId,
origin: "app_owned",
createdBy: "agent",
driver,
providerInstanceId: instanceId,
providerThreadId: null,
childThreadId: null,
nativeTaskRef: null,
prompt: "Review the change",
title: "Review",
model: null,
completionWake: "always",
completionDelivery: { state: "claimed", observedByRunId: null },
status: "failed",
result: "Usage limit reached.",
startedAt: now,
completedAt: now,
updatedAt: now,
},
},
],
});
yield* orchestrator.dispatch({
type: "message.dispatch",
commandId: CommandId.make("notice"),
threadId,
messageId,
text: "Delegated task finished",
attachments: [],
dispatchMode: { type: "queue_after_active" },
createdBy: "agent",
creationSource: "server",
delegatedCompletion: { parentRunId: parent.runId, generation: 1, taskIds: [taskId] },
});
yield* worker.drain();
const after = yield* orchestrator.getThreadProjection(threadId);
assert.deepEqual(
{
steeredIntoRunningTurn: steered.length,
queuedRuns: after.runs.filter((run) => run.status === "queued").length,
},
{ steeredIntoRunningTurn: 1, queuedRuns: 0 },
);
assert.equal(steered[0]?.runId, parent.runId);
}).pipe(
Effect.provide(
ProviderReplayHarness.layerWithRegistry(
{ name: `claude-notice-steering-${provider}` },
ProviderAdapterRegistry.layerSingle(adapter),
{ runEffectWorker: false },
),
),
);
}),
);Posted by Claude Opus 5.5 on behalf of Alb11747.
Activity
juliusmarminge commentedon Nov 14, 2026
I tried to reproduce this on the current build and could not.
Commit a6d9140 (origin/main). No live Claude run: that needs first-party Claude OAuth, which no worker has. I saved the reporter's test as apps/server/src/orchestration-v2/ClaudeNoticeSteering.integration.test.ts and ran
vp test runon it from apps/server. It drives the real Orchestrator, EventSink and EffectWorker through ProviderReplayHarness with a fake adapter whose only difference between the two cases is the declared capabilities. Codex case: the delegated-task completion is steered into the running parent turn (passes). Claude case: it fails with { steeredIntoRunningTurn: 0, queuedRuns: 1 }, so the notice becomes a queued run that starts only after the parent turn ends. This matches the live Claude timeline posted on #36106 (child failed 4 s after it started; parent told 3 min 23 s later, when its turn ended).Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.
Alb11747 commentedon Nov 14, 2026
Steps and version, as asked. A live Claude run is already recorded on #36106 by mselcukozturk, on T3 Code 0.1.7 (Linux, desktop + local server):
delegate_task(async) to any child that fails quickly, for example a model whose quota is spent.Result: the child failed 4 s after it was requested. The completion became a queued run, and the parent learned of it 3 min 23 s later, 0.17 s after its own turn ended. A Codex parent gets the same notice steered into its running turn.
The routing is unchanged on current
main(60e4be4:ClaudeAdapterV2.ts:530,Orchestrator.ts:7178), and the closing comment above shows the Claude case failing with{ steeredIntoRunningTurn: 0, queuedRuns: 1 }. The cause is thatClaudeAdapterV2declaresactiveSteeringInterruptsTools: true, so notices are not steered. It can't simply be flipped while Claude steers cancel in-flight MCP tools (#21895). #35462 fixes that first.Posted by Claude Opus 5.5 on behalf of Alb11747.