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

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

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

Visibility: public

[fix\(server\): changing the permission mode no longer ends the running turn](https://git.cafe/pingdotgg/t3code/pull/23082.md)

Version: 4

Head commit: 7d2f9afa530ffaa31e5b7335d2bb59b9f585f01a

Base commit: e78f58046876271109bf6985e8f46769d8e7505d

Comparison base: d13fc846700e5bb7d63e1bfaabb1e55733912b21

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

## apps/server/src/orchestration-v2/Orchestrator.ts

Status: modified; +3; −14

```diff
@@ -2916,7 +2916,6 @@
     }
 
     const needsProviderState =
-      command.type === "thread.runtime-mode.set" ||
       command.type === "thread.model-selection.set" ||
       command.type === "provider.switch" ||
       command.type === "thread.archive" ||
@@ -3673,13 +3672,7 @@
             command.worktreePath !== undefined &&
             command.worktreePath !== thread.worktreePath
           ? (providerContext?.providerSessions ?? []).map((session) => session.id)
+          : (providerSwitchPlan?.releaseProviderSessionIds ?? []),
-          : command.type === "thread.runtime-mode.set"
-            ? (providerContext?.providerSessions ?? [])
-                .filter(
-                  (session) => !session.capabilities.sessions.supportsRuntimeModeSwitchInSession,
-                )
-                .map((session) => session.id)
-            : (providerSwitchPlan?.releaseProviderSessionIds ?? []),
     );
     if (detachSessionIds.size > 0) {
       const liveSessions = (providerContext?.providerSessions ?? []).filter(
@@ -3711,9 +3704,7 @@
                       ? "Thread settled."
                       : command.type === "thread.metadata.update"
                         ? "Workspace changed."
-                        : command.type === "thread.runtime-mode.set"
-                          ? "Runtime mode changed."
-                          : "Provider or model selection changed.",
+                        : "Provider or model selection changed.",
               },
             });
             const pendingEffect = {
@@ -3730,9 +3721,7 @@
                       ? "Thread settled."
                       : command.type === "thread.metadata.update"
                         ? "Workspace changed."
-                        : command.type === "thread.runtime-mode.set"
-                          ? "Runtime mode changed."
-                          : "Provider or model selection changed.",
+                        : "Provider or model selection changed.",
                 // Terminal detaches revoke the thread's MCP credentials; other
                 // detach reasons keep them so a re-attaching provider process
                 // stays authorized.
```

## apps/server/src/orchestration-v2/ProviderSessionManager.ts

Status: modified; +49; −1

```diff
@@ -10,6 +10,7 @@
   OrchestrationV2RuntimeRequest,
   ProviderInstanceId,
   ProviderSessionId,
+  type RuntimeMode,
   ThreadId,
   type ProviderThreadId,
 } from "@t3tools/contracts";
@@ -83,6 +84,17 @@
   }
 }
 
+export class ProviderSessionBackgroundWorkBlocksModeChangeError extends Schema.TaggedError<ProviderSessionBackgroundWorkBlocksModeChangeError>()(
+  "ProviderSessionBackgroundWorkBlocksModeChangeError",
+  {
+    providerSessionId: ProviderSessionId,
+  },
+) {
+  override get message(): string {
+    return "The agent is still running background agents or commands, and applying the new permission mode would end them. Wait for them to finish, or press Stop, then send the message again.";
+  }
+}
+
 export class ProviderSessionLookupError extends Schema.TaggedError<ProviderSessionLookupError>()(
   "ProviderSessionLookupError",
   {
@@ -134,6 +146,7 @@
 
 export const ProviderSessionManagerV2Error = Schema.Union([
   ProviderSessionOpenError,
+  ProviderSessionBackgroundWorkBlocksModeChangeError,
   ProviderWorkspaceMissingError,
   ProviderSessionLookupError,
   ProviderSessionCloseError,
@@ -198,6 +211,12 @@
    */
   readonly mcpCredentialIdByThread: ReadonlyMap<ThreadId, string>;
   readonly supportsMultipleProviderThreads: boolean;
+  /**
+   * The mode the session opened with. A session that cannot switch modes in
+   * place is restarted when a later turn needs another one; a mode change
+   * never ends the turn it lands in.
+   */
+  readonly runtimeMode: RuntimeMode;
   readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime;
   readonly exposedRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime;
   readonly eventSubscribers: Ref.Ref<
@@ -2118,7 +2137,35 @@
                 }
               }
               const key = sessionKey(input.providerSessionId);
+              let existing = (yield* Ref.get(sessions)).get(key);
+              if (
-              const existing = (yield* Ref.get(sessions)).get(key);
+                existing !== undefined &&
+                existing.runtimeMode !== input.runtimePolicy.runtimeMode &&
+                !existing.runtime.providerSession.capabilities.sessions
+                  .supportsRuntimeModeSwitchInSession
+              ) {
+                // Restarting ends the process's background agents and commands,
+                // so it waits for them like a Claude model change does.
+                const probedRuntime = existing.runtime;
+                if (
+                  probedRuntime.hasPendingBackgroundWork !== undefined &&
+                  (yield* probedRuntime.hasPendingBackgroundWork.pipe(
+                    Effect.catchCause(() => Effect.succeed(false)),
+                  ))
+                ) {
+                  return yield* new ProviderSessionBackgroundWorkBlocksModeChangeError({
+                    providerSessionId: input.providerSessionId,
+                  });
+                }
+                // Only single-thread sessions lack in-place switching, so no
+                // other thread shares the process being replaced.
+                yield* releaseEntry({
+                  providerSessionId: input.providerSessionId,
+                  reason: "manual_shutdown",
+                  detail: "Runtime mode changed.",
+                });
+                existing = undefined;
+              }
               if (existing !== undefined) {
                 const attached = existing.attachedThreadIds.has(input.threadId);
                 if (!attached && !existing.supportsMultipleProviderThreads) {
@@ -2230,6 +2277,7 @@
                 supportsMultipleProviderThreads:
                   runtime.providerSession.capabilities.sessions
                     .supportsMultipleProviderThreadsPerSession,
+                runtimeMode: input.runtimePolicy.runtimeMode,
                 runtime,
                 exposedRuntime,
                 eventSubscribers,
```

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

Status: modified; +172; −0

```diff
@@ -4,6 +4,7 @@
   EventId,
   MessageId,
   type ModelSelection,
+  type OrchestrationV2DomainEvent,
   type OrchestrationV2ProviderCapabilities,
   type OrchestrationV2ProviderSession,
   type OrchestrationV2ProviderThread,
@@ -14,6 +15,7 @@
   ProviderThreadId,
   ProviderTurnId,
   type RunId,
+  type RuntimeMode,
   ThreadId,
 } from "@t3tools/contracts";
 import * as DateTime from "effect/DateTime";
@@ -67,11 +69,13 @@
   readonly opened: ReadonlyArray<{
     readonly model: string | null;
     readonly cwd: string | null;
+    readonly runtimeMode: RuntimeMode;
   }>;
   readonly started: ReadonlyArray<{
     readonly model: string;
     readonly cwd: string | null;
     readonly attemptId: string;
+    readonly runtimeMode: RuntimeMode;
   }>;
   readonly closedSessionCount: number;
   readonly failedReplacementOpen: boolean;
@@ -108,6 +112,7 @@
                 {
                   model: sessionInput.modelSelection.model,
                   cwd: sessionInput.runtimePolicy.cwd,
+                  runtimeMode: sessionInput.runtimePolicy.runtimeMode,
                 },
               ],
             },
@@ -218,6 +223,7 @@
                     model: input.modelSelection.model,
                     cwd: input.runtimePolicy.cwd,
                     attemptId: input.attemptId,
+                    runtimeMode: input.runtimePolicy.runtimeMode,
                   },
                 ],
               }));
@@ -437,7 +443,173 @@
     }),
   ),
 );
+
+/**
+ * Starts a Supervised turn on a provider that fixes its mode when the session
+ * opens (like Claude), switches the thread to Auto mid-turn, stops that turn,
+ * and sends another message.
+ */
+const switchModeMidTurn = (name: string, options: { readonly backgroundWork: boolean }) =>
+  Effect.gen(function* () {
+    const cwd = yield* checkpointWorkspace(name);
+    const threadId = ThreadId.make(`thread:${name}`);
+    const state = yield* Ref.make<RestartAdapterState>({
+      activeTurn: null,
+      opened: [],
+      started: [],
+      closedSessionCount: 0,
+      failedReplacementOpen: true,
+    });
+    const base = makeRestartAdapter(state, {
+      ...exclusiveCapabilities,
+      sessions: {
+        ...exclusiveCapabilities.sessions,
+        supportsRuntimeModeSwitchInSession: false,
+      },
+    });
+    const layerRegistry = ProviderAdapterRegistry.layerSingle({
+      ...base,
+      openSession: (input) =>
+        base.openSession(input).pipe(
+          Effect.map((session) => ({
+            ...session,
+            hasPendingBackgroundWork: Effect.succeed(options.backgroundWork),
+          })),
+        ),
+    });
+
+    const result = yield* Effect.gen(function* () {
+      const orchestrator = yield* Orchestrator.OrchestratorV2;
+      const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
+      const send = (step: string) =>
+        Effect.gen(function* () {
+          const settled = yield* orchestrator.streamDomainEvents.pipe(
+            Stream.filter(
+              (event) =>
+                (event.type === "provider-turn.updated" && event.payload.status === "running") ||
+                (event.type === "run.updated" && event.payload.status === "failed"),
+            ),
+            Stream.take(1),
+            Stream.runDrain,
+            Effect.forkScoped,
+          );
+          yield* orchestrator.dispatch({
+            type: "message.dispatch",
+            createdBy: "user",
+            creationSource: "web",
+            commandId: CommandId.make(`${name}:${step}`),
+            threadId,
+            messageId: MessageId.make(`${name}:${step}`),
+            text: step,
+            attachments: [],
+            modelSelection: initialSelection,
+            dispatchMode: { type: "start_immediately" },
+          });
+          yield* Fiber.join(settled);
+          yield* worker.drain();
+        });
+      yield* orchestrator.dispatch({
+        type: "thread.create",
+        createdBy: "user",
+        creationSource: "web",
+        commandId: CommandId.make(`${name}:create`),
+        threadId,
+        projectId: ProjectId.make(`project:${name}`),
+        title: name,
+        modelSelection: initialSelection,
+        runtimeMode: "approval-required",
+        interactionMode: "default",
+        branch: null,
+        worktreePath: cwd,
+      });
+      yield* send("first");
 
+      yield* orchestrator.dispatch({
+        type: "thread.runtime-mode.set",
+        commandId: CommandId.make(`${name}:auto`),
+        threadId,
+        runtimeMode: "auto",
+      });
+      yield* worker.drain();
+      const midTurn = yield* orchestrator.getThreadProjection(threadId);
+      const closedMidTurn = (yield* Ref.get(state)).closedSessionCount;
+
+      const interrupted = yield* orchestrator.streamDomainEvents.pipe(
+        Stream.filter(
+          (event) => event.type === "run.updated" && event.payload.status === "interrupted",
+        ),
+        Stream.take(1),
+        Stream.runDrain,
+        Effect.forkScoped,
+      );
+      yield* orchestrator.dispatch({
+        type: "run.interrupt",
+        commandId: CommandId.make(`${name}:stop`),
+        threadId,
+        runId: midTurn.runs[0]!.id,
+      });
+      yield* worker.drain();
+      yield* Fiber.join(interrupted);
+      yield* send("second");
+      return {
+        midTurn,
+        closedMidTurn,
+        final: yield* orchestrator.getThreadProjection(threadId),
+        captured: yield* Ref.get(state),
+      };
+    }).pipe(Effect.provide(ProviderReplayHarness.layerWithRegistry({ name }, layerRegistry)));
+    return result;
+  });
+
+it.live("a runtime mode change waits for the next turn instead of ending the current one", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const { midTurn, closedMidTurn, captured } = yield* switchModeMidTurn(
+        "runtime-mode-mid-turn",
+        { backgroundWork: false },
+      );
+      assert.equal(midTurn.thread.runtimeMode, "auto");
+      assert.equal(closedMidTurn, 0);
+      assert.deepEqual(
+        midTurn.runs.map((run) => run.status),
+        ["running"],
+      );
+      assert.equal(captured.closedSessionCount, 1);
+      assert.deepEqual(
+        captured.opened.map((open) => open.runtimeMode),
+        ["approval-required", "auto"],
+      );
+      assert.deepEqual(
+        captured.started.map((turn) => turn.runtimeMode),
+        ["approval-required", "auto"],
+      );
+    }),
+  ),
+);
+
+it.live("a runtime mode change does not end background work to restart the session", () =>
+  Effect.scoped(
+    Effect.gen(function* () {
+      const { final, captured } = yield* switchModeMidTurn("runtime-mode-background-work", {
+        backgroundWork: true,
+      });
+      assert.equal(captured.closedSessionCount, 0);
+      assert.lengthOf(captured.opened, 1);
+      assert.deepEqual(
+        final.runs.map((run) => run.status),
+        ["interrupted", "failed"],
+      );
+      assert.isTrue(
+        final.turnItems.some(
+          (item) =>
+            item.type === "error" &&
+            item.failure.message.startsWith("The agent is still running background agents"),
+        ),
+      );
+    }),
+  ),
+);
+
 it.live(
   "restarts the live session on a model change when a newer stopped session record exists",
   () =>
```
