# Files changed · PR \#23892 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23892/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(server\): let Claude steer interrupt a running Bash command](https://git.cafe/pingdotgg/t3code/pull/23892.md)

Version: 13

Head commit: 9223781a8782ad19f504a78dadb03772196dbc7a

Base commit: 200dad3b39aa9c2562b4bae54106ab8acdcc4163

Comparison base: 52bb5c8c4e0908e4ef10428c12258be55b964f39

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts

Status: modified; +204; −1

```diff
@@ -2136,7 +2136,7 @@
 
   const makeWakeHarnessWithOptions = (options?: {
     readonly close?: (sdkMessages: Queue.Queue<SDKMessage>) => Effect.Effect<void>;
-    readonly interrupt?: Effect.Effect<void>;
+    readonly interrupt?: Effect.Effect<void, ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError>;
     readonly setPermissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["setPermissionMode"];
     readonly stopTask?: (
       taskId: string,
@@ -3566,6 +3566,209 @@
     ),
   );
 
+  // #15720: priority "now" alone leaves a running foreground Bash command
+  // blocking the CLI, so the steer is never answered until it exits.
+  const steerDuringRunningBash = Effect.fn("steerDuringRunningBash")(function* (
+    harness: Effect.Success<ReturnType<typeof makeWakeHarnessWithOptions>>,
+    name: string,
+  ) {
+    const idAllocator = yield* IdAllocator.IdAllocatorV2;
+    const attemptId = RunAttemptId.make(`attempt-${name}`);
+    const input = makeClaudeTestTurnInput({
+      threadId: harness.threadId,
+      providerThread: harness.providerThread,
+      now: yield* DateTime.now,
+      attemptId,
+      text: "Run the slow request.",
+      attachments: [],
+    });
+    yield* harness.runtime.startTurn(input);
+    yield* harness.offerAndWait(
+      claudeSdkFrame({
+        type: "assistant",
+        message: {
+          model: "claude-sonnet-4-6",
+          id: `msg_${name}`,
+          type: "message",
+          role: "assistant",
+          content: [
+            {
+              type: "tool_use",
+              id: "toolu_steer_curl",
+              name: "Bash",
+              input: { command: "curl --max-time 180 http://127.0.0.1:15820/" },
+            },
+          ],
+        },
+        parent_tool_use_id: null,
+        uuid: "00000000-0000-4000-8000-000000000910",
+        session_id: WAKE_NATIVE_SESSION,
+      }),
+    );
+    return {
+      steer: harness.runtime.steerTurn({
+        threadId: harness.threadId,
+        runId: input.runId,
+        providerThread: harness.providerThread,
+        providerTurnId: idAllocator.derive.providerTurn({
+          driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
+          nativeTurnId: `turn:${attemptId}`,
+        }),
+        message: {
+          createdBy: "user",
+          creationSource: "web",
+          messageId: MessageId.make(`message-${name}`),
+          text: "Change of plan: stop waiting for that command and reply with exactly STEERED_NOW.",
+          attachments: [],
+        },
+      }),
+    };
+  });
+  const steerTestLayer = Layer.mergeAll(
+    IdAllocator.layer,
+    McpProviderSessions.layer,
+    NodeServices.layer,
+  );
+
+  it.effect("steering releases a running foreground command", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const releases: Array<string> = [];
+        const harness = yield* makeWakeHarnessWithOptions({
+          interrupt: Effect.sync(() => {
+            releases.push("interrupt");
+          }),
+          backgroundTasks: (toolUseId) =>
+            Effect.sync(() => {
+              releases.push(`background:${toolUseId}`);
+              return true;
+            }),
+        });
+        const { steer } = yield* steerDuringRunningBash(harness, "steer-running-command");
+        yield* steer;
+        assert.equal(harness.offeredMessages[1]?.priority, "now");
+        assert.deepEqual(
+          releases,
+          ["interrupt"],
+          "steer left the running Bash command blocking the turn",
+        );
+      }).pipe(Effect.provide(steerTestLayer)),
+    ),
+  );
+
+  // Interrupting aborts every pending permission callback, which would deny a
+  // question or approval the user never answered.
+  it.effect("steering leaves the command running while the user is asked something", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        let interrupted = false;
+        const harness = yield* makeWakeHarnessWithOptions({
+          interrupt: Effect.sync(() => {
+            interrupted = true;
+          }),
+        });
+        const { steer } = yield* steerDuringRunningBash(harness, "steer-pending-question");
+        const question = new AbortController();
+        yield* Effect.promise(() =>
+          harness.getOpenedOptions()!.canUseTool!(
+            "AskUserQuestion",
+            {
+              questions: [
+                {
+                  header: "Target",
+                  question: "Which environment?",
+                  options: [
+                    { label: "Production", description: "Deploy to production." },
+                    { label: "Staging", description: "Deploy to staging." },
+                  ],
+                  multiSelect: false,
+                },
+              ],
+            },
+            {
+              signal: question.signal,
+              toolUseID: "toolu_steer_question",
+              requestId: "request-steer-question",
+            },
+          ),
+        ).pipe(Effect.forkScoped);
+        yield* Queue.take(harness.runtimeRequestReceipts);
+        yield* steer;
+        assert.isFalse(interrupted, "steer cancelled the pending question");
+        assert.equal(harness.offeredMessages[1]?.priority, "now");
+      }).pipe(Effect.provide(steerTestLayer)),
+    ),
+  );
+
+  it.effect("a steer is not sent when the turn ends while the command is interrupted", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const interruptStarted = yield* Deferred.make<void>();
+        const interruptAnswered = yield* Deferred.make<void>();
+        const harness = yield* makeWakeHarnessWithOptions({
+          interrupt: Deferred.succeed(interruptStarted, undefined).pipe(
+            Effect.andThen(Deferred.await(interruptAnswered)),
+          ),
+        });
+        const { steer } = yield* steerDuringRunningBash(harness, "steer-turn-ends");
+        const steering = yield* Effect.exit(steer).pipe(Effect.forkScoped);
+        yield* Deferred.await(interruptStarted);
+        // The command finished on its own before the interrupt reached it.
+        yield* harness.offerAndWait(
+          makeResultFrame({
+            uuid: "00000000-0000-4000-8000-000000000911",
+            result: "Done.",
+          }),
+        );
+        yield* Queue.take(harness.terminalReceipts);
+        yield* Deferred.succeed(interruptAnswered, undefined);
+        const exit = yield* Fiber.join(steering);
+        assert.isTrue(Exit.isFailure(exit), "steer reported success after its turn ended");
+        assert.equal(harness.offeredMessages.length, 1, "steer started untracked native work");
+      }).pipe(Effect.provide(steerTestLayer)),
+    ),
+  );
+
+  // The CLI may already have aborted the command, and its aborted result is
+  // swallowed as a steered segment, so only the steer can continue the turn.
+  it.effect("a steer is still sent when interrupting the command fails", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const harness = yield* makeWakeHarnessWithOptions({
+          interrupt: Effect.fail(
+            new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({
+              method: "interrupt",
+              cause: new Error("control request failed"),
+            }),
+          ),
+        });
+        const { steer } = yield* steerDuringRunningBash(harness, "steer-interrupt-fails");
+        yield* steer;
+        assert.equal(harness.offeredMessages[1]?.priority, "now");
+      }).pipe(Effect.provide(steerTestLayer)),
+    ),
+  );
+
+  // The SDK puts no deadline on the interrupt control request.
+  it.effect("a steer is still sent when the command interrupt is never answered", () =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const interruptStarted = yield* Deferred.make<void>();
+        const harness = yield* makeWakeHarnessWithOptions({
+          interrupt: Deferred.succeed(interruptStarted, undefined).pipe(
+            Effect.andThen(Effect.never),
+          ),
+        });
+        const { steer } = yield* steerDuringRunningBash(harness, "steer-interrupt-unanswered");
+        const steering = yield* steer.pipe(Effect.forkScoped);
+        yield* Deferred.await(interruptStarted);
+        yield* TestClock.adjust("5 seconds");
+        yield* Fiber.join(steering);
+        assert.equal(harness.offeredMessages[1]?.priority, "now");
+      }).pipe(Effect.provide(steerTestLayer)),
+    ),
+  );
+
   it.effect("announces usage-limit pauses once per window and again on a new turn", () =>
     Effect.gen(function* () {
       const harness = yield* makeWakeHarness;
```

## apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts

Status: modified; +44; −12

```diff
@@ -313,6 +313,9 @@
 // A steer waits at most this long, from its request, for its foreground
 // subagents to move to the background before it is sent.
 const BACKGROUND_MOVE_TIMEOUT_MS = 5_000;
+// A steer waits at most this long for the CLI to answer the interrupt of a
+// running command before it is sent anyway.
+const CLAUDE_STEER_INTERRUPT_TIMEOUT = "5 seconds";
 
 const CLAUDE_GROUP_WATCH_SCRIPT = [
   "{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } </dev/null >/dev/null 2>&1 &",
@@ -9917,24 +9920,53 @@
               fileSystem,
               skills: yield* userInvocableSkills(currentTurn.input.runtimePolicy.cwd),
             });
+            // The turn can end while the moves or the interrupt are answered. A
+            // steer offered then would start a new turn the orchestrator never
+            // asked for, so the steer fails and the orchestrator starts the
+            // follow-up turn.
+            const failIfTurnEnded = Effect.gen(function* () {
+              if (
+                (yield* Ref.get(activeTurn))?.providerTurnId !== turnInput.providerTurnId ||
+                (yield* Ref.get(queryContext)) !== existing
+              ) {
+                return yield* new ProviderAdapter.ProviderAdapterProtocolError({
+                  driver: CLAUDE_PROVIDER,
+                  detail: `Claude provider turn ${turnInput.providerTurnId} ended before the steer was sent.`,
+                });
+              }
+            });
             yield* backgroundForegroundSubagents(existing, backgroundDeadline);
-            // The turn can end while the moves are answered. A steer offered
-            // then would start a new turn the orchestrator never asked for, so
-            // the steer fails and the orchestrator starts the follow-up turn.
-            if (
-              (yield* Ref.get(activeTurn))?.providerTurnId !== turnInput.providerTurnId ||
-              (yield* Ref.get(queryContext)) !== existing
-            ) {
-              return yield* new ProviderAdapter.ProviderAdapterProtocolError({
-                driver: CLAUDE_PROVIDER,
-                detail: `Claude provider turn ${turnInput.providerTurnId} ended before the steer was sent.`,
-              });
-            }
+            yield* failIfTurnEnded;
             yield* Ref.update(steeredTurns, (current) => {
               const next = new Set(current);
               next.add(turnInput.providerTurnId);
               return next;
             });
+            // Priority "now" does not stop a foreground command (#15720), so a
+            // running Bash command is interrupted first, as Esc would. Its
+            // aborted_tools result ends only this segment of the steered turn.
+            // Interrupting also aborts pending permission callbacks, so a
+            // question or approval the user has not answered keeps the command.
+            if (
+              [...currentTurn.toolCalls.values()].some((call) => call.toolName === "Bash") &&
+              (yield* Ref.get(pendingRuntimeRequests)).size === 0
+            ) {
+              // The CLI may have aborted before the request failed or timed out,
+              // and that result is swallowed, so the steer is still sent to continue.
+              yield* existing.query.interrupt.pipe(
+                Effect.timeoutOrElse({
+                  duration: CLAUDE_STEER_INTERRUPT_TIMEOUT,
+                  orElse: () =>
+                    Effect.fail(
+                      queryRunnerError("Claude Code did not answer the interrupt.", "interrupt"),
+                    ),
+                }),
+                Effect.catch((cause) =>
+                  Effect.logWarning("orchestration-v2.claude-steer-interrupt-failed", { cause }),
+                ),
+              );
+              yield* failIfTurnEnded;
+            }
             yield* existing.query.offer(userMessage);
           },
           (effect, turnInput) =>
```

## apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts

Status: modified; +6; −2

```diff
@@ -169,7 +169,11 @@
                 setPermissionMode: () => Effect.void,
                 backgroundTasks: () => Effect.succeed(false),
                 stopTask: () => Effect.void,
-                interrupt: Effect.die("automatic delivery must never interrupt"),
+                // A steer interrupts the running Bash command as Esc would (#15720).
+                interrupt:
+                  delivery === "user steering" || delivery === "promoted queued message"
+                    ? Effect.sync(() => batchAbort.abort({ kind: "interrupt" }))
+                    : Effect.die("automatic delivery must never interrupt"),
                 close: Effect.void,
               }),
             forkSession: () => Effect.die("unused"),
@@ -408,7 +412,7 @@
               event.payload.id === parent.id &&
               event.payload.status === "waiting",
           );
-          yield* Queue.offer(sdkMessages, toolResult("build", false));
+          yield* Queue.offer(sdkMessages, toolResult("build", batchAbort.signal.aborted));
           for (const id of statusIds)
             yield* Queue.offer(sdkMessages, toolResult(id, batchAbort.signal.aborted));
           if (explicitSteer) yield* Queue.offer(sdkMessages, result("aborted", true));
```
