authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 02:44:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 02:44:44-07:00
logbc95bbd790580ff22bf1351edfa84231493c4ecc
tree860c1174e8f2b9bd0830a7b939eae73fa20e98dd
parent9fb4ddb2d1ccad4452b15c2934170da275cb8bc6
signature Signed by PGP key B5690EEEBB952194

fix(server): changing Claude effort no longer fails while background agents run (#19341)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 196 insertions(+), 56 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+90-52
......@@ -132,6 +132,12 @@ const CLAUDE_TEST_MODEL_SELECTION = {
132132 model: "claude-sonnet-4-6",
133133 options: [{ id: "effort", value: "ultrathink" }],
134134} satisfies ModelSelection;
135// Thinking is a launch flag, so switching to this selection needs a new process.
136const CLAUDE_TEST_RESTART_MODEL_SELECTION = {
137 ...CLAUDE_TEST_MODEL_SELECTION,
138 model: "claude-haiku-4-5-20251001",
139 options: [{ id: "thinking", value: false }],
140} satisfies ModelSelection;
135141const CLAUDE_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
136142 runtimeMode: "full-access",
137143 interactionMode: "default",
......@@ -1027,6 +1033,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
10271033 messages: Stream.never,
10281034 offer: () => Effect.void,
10291035 setModel: () => Effect.void,
1036 applyFlagSettings: () => Effect.void,
10301037 setPermissionMode: () => Effect.void,
10311038 stopTask: () => Effect.void,
10321039 interrupt: Effect.void,
......@@ -1187,6 +1194,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio
11871194 messages: Stream.never,
11881195 offer: () => Effect.void,
11891196 setModel: () => Effect.void,
1197 applyFlagSettings: () => Effect.void,
11901198 setPermissionMode: () => Effect.void,
11911199 stopTask: () => Effect.void,
11921200 interrupt: Effect.void,
......@@ -1307,6 +1315,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {
13071315 messages: Stream.never,
13081316 offer: () => Effect.void,
13091317 setModel: () => Effect.void,
1318 applyFlagSettings: () => Effect.void,
13101319 setPermissionMode: () => Effect.void,
13111320 stopTask: () => Effect.void,
13121321 interrupt: Effect.void,
......@@ -1533,6 +1542,7 @@ describe("ClaudeAdapterV2 attachments", () => {
15331542 offeredMessages.push(message);
15341543 }),
15351544 setModel: () => Effect.void,
1545 applyFlagSettings: () => Effect.void,
15361546 setPermissionMode: () => Effect.void,
15371547 stopTask: () => Effect.void,
15381548 interrupt: Effect.void,
......@@ -1678,6 +1688,7 @@ describe("ClaudeAdapterV2 attachments", () => {
16781688 messages: Stream.never,
16791689 offer: () => Effect.void,
16801690 setModel: () => Effect.void,
1691 applyFlagSettings: () => Effect.void,
16811692 setPermissionMode: () => Effect.void,
16821693 stopTask: () => Effect.void,
16831694 interrupt: Effect.void,
......@@ -1773,6 +1784,7 @@ describe("ClaudeAdapterV2 native fork", () => {
17731784 messages: Stream.empty,
17741785 offer: () => Effect.void,
17751786 setModel: () => Effect.void,
1787 applyFlagSettings: () => Effect.void,
17761788 setPermissionMode: () => Effect.void,
17771789 stopTask: () => Effect.void,
17781790 interrupt: Effect.void,
......@@ -2065,6 +2077,7 @@ describe("ClaudeAdapterV2 native session identity", () => {
20652077 messages: Stream.empty,
20662078 offer: () => Effect.void,
20672079 setModel: () => Effect.void,
2080 applyFlagSettings: () => Effect.void,
20682081 setPermissionMode: () => Effect.void,
20692082 stopTask: () => Effect.void,
20702083 interrupt: Effect.void,
......@@ -2356,6 +2369,8 @@ describe("ClaudeAdapterV2 background wake turns", () => {
23562369 });
23572370 const offeredMessages: Array<SDKUserMessage> = [];
23582371 const permissionModeChanges: Array<string> = [];
2372 // setModel and applyFlagSettings calls on the first process, in order.
2373 const liveSelectionChanges: Array<unknown> = [];
23592374 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
23602375 [];
23612376 const continuationReceipts =
......@@ -2403,6 +2418,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
24032418 offeredMessages.push(message);
24042419 }),
24052420 setModel: () => Effect.void,
2421 applyFlagSettings: () => Effect.void,
24062422 setPermissionMode: () => Effect.void,
24072423 stopTask: options?.stopTask ?? (() => Effect.void),
24082424 interrupt: Effect.void,
......@@ -2432,7 +2448,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
24322448 Effect.sync(() => {
24332449 offeredMessages.push(message);
24342450 }),
2435 setModel: () => Effect.void,
2451 setModel: (model) =>
2452 Effect.sync(() => {
2453 liveSelectionChanges.push({ model });
2454 }),
2455 applyFlagSettings: (settings) =>
2456 Effect.sync(() => {
2457 liveSelectionChanges.push(settings);
2458 }),
24362459 setPermissionMode: (mode) =>
24372460 Effect.sync(() => {
24382461 permissionModeChanges.push(mode);
......@@ -2503,6 +2526,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
25032526 return uuid;
25042527 },
25052528 permissionModeChanges,
2529 liveSelectionChanges,
25062530 continuationRequests,
25072531 continuationReceipts,
25082532 events,
......@@ -2627,7 +2651,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
26272651 );
26282652
26292653 it.effect(
2630 "reuses a background shell's query for omitted and explicit Normal, but blocks Fast",
2654 "applies model, effort and Fast changes to a process running a background shell, but not thinking or a cleared effort",
26312655 () =>
26322656 Effect.scoped(
26332657 Effect.gen(function* () {
......@@ -2648,41 +2672,65 @@ describe("ClaudeAdapterV2 background wake turns", () => {
26482672 providerTurnOrdinal: ordinal,
26492673 modelSelection,
26502674 });
2675 const settle = Effect.gen(function* () {
2676 yield* harness.offerAndWait(turnOneResult);
2677 yield* Queue.take(harness.terminalReceipts);
2678 });
26512679 yield* harness.runtime.startTurn(turn(1, normal));
26522680 const originalOptions = harness.getOpenedOptions();
26532681 yield* harness.offerAndWait(wakeTaskStarted);
2654 yield* harness.offerAndWait(turnOneResult);
2655 yield* Queue.take(harness.terminalReceipts);
2682 yield* settle;
26562683 assert.isTrue(yield* harness.hasPendingBackgroundWork);
26572684
2685 // Omitted and explicit Normal are the same selection.
26582686 yield* harness.runtime.startTurn(
2659 turn(2, {
2687 turn(2, { ...normal, options: [{ id: "fastMode", value: false }] }),
2688 );
2689 yield* settle;
2690 assert.deepEqual(harness.liveSelectionChanges, []);
2691
2692 yield* harness.runtime.startTurn(
2693 turn(3, {
26602694 ...normal,
2661 options: [{ id: "fastMode", value: false }],
2695 options: [
2696 { id: "effort", value: "ultracode" },
2697 { id: "fastMode", value: true },
2698 ],
26622699 }),
26632700 );
2701 yield* settle;
2702 yield* harness.runtime.startTurn(turn(4, { ...normal, model: "claude-sonnet-4-6" }));
2703 yield* settle;
2704 assert.deepEqual(harness.liveSelectionChanges, [
2705 { model: "claude-opus-5-5" },
2706 { effortLevel: "xhigh", fastMode: true, ultracode: true },
2707 { model: "claude-sonnet-4-6" },
2708 // Sonnet's default effort; it has no Fast mode.
2709 { effortLevel: "high", fastMode: null, ultracode: null },
2710 ]);
26642711 assert.strictEqual(harness.getOpenedOptions(), originalOptions);
2665 assert.lengthOf(harness.offeredMessages, 2);
2666 yield* harness.offerAndWait(turnOneResult);
2667 yield* Queue.take(harness.terminalReceipts);
2668
2669 const refused = yield* harness.runtime
2670 .startTurn(
2671 turn(3, {
2672 ...normal,
2673 options: [{ id: "fastMode", value: true }],
2674 }),
2675 )
2676 .pipe(Effect.result);
2677 assert.equal(refused._tag, "Failure");
2678 if (refused._tag === "Failure") {
2679 assert.instanceOf(
2680 refused.failure.cause,
2681 ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError,
2682 );
2712 assert.lengthOf(harness.offeredMessages, 4);
2713
2714 // Thinking is a launch flag. Haiku 4.5 has no effort, and only a new
2715 // process reads the user's configured effort again.
2716 for (const [ordinal, selection] of [
2717 [5, CLAUDE_TEST_RESTART_MODEL_SELECTION],
2718 [6, { ...normal, model: "claude-haiku-4-5-20251001" }],
2719 ] as const) {
2720 const refused = yield* harness.runtime
2721 .startTurn(turn(ordinal, selection))
2722 .pipe(Effect.result);
2723 assert.equal(refused._tag, "Failure");
2724 if (refused._tag === "Failure") {
2725 assert.instanceOf(
2726 refused.failure.cause,
2727 ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError,
2728 );
2729 }
26832730 }
26842731 assert.strictEqual(harness.getOpenedOptions(), originalOptions);
2685 assert.lengthOf(harness.offeredMessages, 2);
2732 assert.lengthOf(harness.offeredMessages, 4);
2733 assert.lengthOf(harness.liveSelectionChanges, 4);
26862734 assert.isTrue(yield* harness.hasPendingBackgroundWork);
26872735 }).pipe(
26882736 Effect.provide(
......@@ -4948,6 +4996,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
49484996 messages: Stream.fromQueue(sdkMessages),
49494997 offer: () => Effect.void,
49504998 setModel: () => Effect.void,
4999 applyFlagSettings: () => Effect.void,
49515000 setPermissionMode: () => Effect.void,
49525001 stopTask: () => Effect.void,
49535002 interrupt: Effect.void,
......@@ -5208,6 +5257,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
52085257 messages: Stream.fromQueue(queue),
52095258 offer: () => Effect.void,
52105259 setModel: () => Effect.void,
5260 applyFlagSettings: () => Effect.void,
52115261 setPermissionMode: () => Effect.void,
52125262 stopTask: () => Effect.void,
52135263 interrupt: Effect.void,
......@@ -10299,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1029910349 messages: Stream.fromQueue(sdkMessages),
1030010350 offer: () => Effect.void,
1030110351 setModel: () => Effect.void,
10352 applyFlagSettings: () => Effect.void,
1030210353 setPermissionMode: () => Effect.void,
1030310354 stopTask: () => Effect.void,
1030410355 interrupt: Effect.void,
......@@ -10370,10 +10421,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1037010421 );
1037110422 assert.isFalse(yield* hasPendingBackgroundWork);
1037210423
10373 const alternateModel = {
10374 ...CLAUDE_TEST_MODEL_SELECTION,
10375 model: "claude-haiku-4-5-20251001",
10376 } satisfies ModelSelection;
10424 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
1037710425 // ProviderTurnStartService marks the thread active before startTurn;
1037810426 // the process-reset clear must preserve that status.
1037910427 const activeProviderThread = {
......@@ -10489,6 +10537,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1048910537 messages: Stream.fromQueue(sdkMessages),
1049010538 offer: () => Effect.void,
1049110539 setModel: () => Effect.void,
10540 applyFlagSettings: () => Effect.void,
1049210541 setPermissionMode: () => Effect.void,
1049310542 stopTask: () => Effect.void,
1049410543 interrupt: Effect.void,
......@@ -10556,10 +10605,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1055610605
1055710606 // User turn changes model, replacing the query while the wake buffer
1055810607 // stays queued for the later provider continuation.
10559 const alternateModel = {
10560 ...CLAUDE_TEST_MODEL_SELECTION,
10561 model: "claude-haiku-4-5-20251001",
10562 } satisfies ModelSelection;
10608 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
1056310609 yield* runtime.startTurn(
1056410610 makeClaudeTestTurnInput({
1056510611 threadId,
......@@ -10731,6 +10777,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1073110777 messages: Stream.fromQueue(sdkMessages),
1073210778 offer: () => Effect.void,
1073310779 setModel: () => Effect.void,
10780 applyFlagSettings: () => Effect.void,
1073410781 setPermissionMode: () => Effect.void,
1073510782 stopTask: () => Effect.void,
1073610783 interrupt: Effect.void,
......@@ -10818,10 +10865,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1081810865 // Model-changing user turn replaces the query while continuation stays
1081910866 // queued. Process reset must not invent opaque classification for the
1082010867 // buffered subagent notification.
10821 const alternateModel = {
10822 ...CLAUDE_TEST_MODEL_SELECTION,
10823 model: "claude-haiku-4-5-20251001",
10824 } satisfies ModelSelection;
10868 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
1082510869 yield* runtime.startTurn(
1082610870 makeClaudeTestTurnInput({
1082710871 threadId,
......@@ -10900,7 +10944,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1090010944 ),
1090110945 );
1090210946
10903 it.effect("refuses a model change that would kill a running background subagent", () =>
10947 it.effect("refuses a thinking change that would kill a running background subagent", () =>
1090410948 Effect.scoped(
1090510949 Effect.gen(function* () {
1090610950 const SUBAGENT_TASK_ID = "task-model-change-running-subagent";
......@@ -10932,6 +10976,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1093210976 messages: Stream.fromQueue(sdkMessages),
1093310977 offer: () => Effect.void,
1093410978 setModel: () => Effect.void,
10979 applyFlagSettings: () => Effect.void,
1093510980 setPermissionMode: () => Effect.void,
1093610981 stopTask: () => Effect.void,
1093710982 interrupt: Effect.void,
......@@ -11004,12 +11049,9 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1100411049 yield* awaitUntil(() => terminals().length === 1, "first turn terminal");
1100511050 const settledTurn = terminals()[0]!;
1100611051
11007 // The subagent runs inside the first CLI process. Another model needs
11008 // another process, so the turn must not start and close this one.
11009 const alternateModel = {
11010 ...CLAUDE_TEST_MODEL_SELECTION,
11011 model: "claude-haiku-4-5-20251001",
11012 } satisfies ModelSelection;
11052 // The subagent runs inside the first CLI process. A thinking change
11053 // needs another process, so the turn must not start and close this one.
11054 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
1101311055 const switchTurn = (attempt: string) =>
1101411056 runtime.startTurn(
1101511057 makeClaudeTestTurnInput({
......@@ -11115,6 +11157,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1111511157 messages: Stream.fromQueue(sdkMessages),
1111611158 offer: () => Effect.void,
1111711159 setModel: () => Effect.void,
11160 applyFlagSettings: () => Effect.void,
1111811161 setPermissionMode: () => Effect.void,
1111911162 stopTask: () => Effect.void,
1112011163 interrupt: Effect.void,
......@@ -11174,10 +11217,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1117411217 );
1117511218 assert.isTrue(yield* hasPendingBackgroundWork);
1117611219
11177 const alternateModel = {
11178 ...CLAUDE_TEST_MODEL_SELECTION,
11179 model: "claude-haiku-4-5-20251001",
11180 } satisfies ModelSelection;
11220 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
1118111221 const failedStart = yield* runtime
1118211222 .startTurn(
1118311223 makeClaudeTestTurnInput({
......@@ -11254,6 +11294,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1125411294 messages: Stream.fromQueue(sdkMessages),
1125511295 offer: () => Effect.void,
1125611296 setModel: () => Effect.void,
11297 applyFlagSettings: () => Effect.void,
1125711298 setPermissionMode: () => Effect.void,
1125811299 stopTask: () => Effect.void,
1125911300 interrupt: Effect.void,
......@@ -11317,10 +11358,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1131711358 yield* awaitUntil(() => continuationRequests.length === 1, "first continuation request");
1131811359 assert.isTrue(yield* hasPendingBackgroundWork);
1131911360
11320 const alternateModel = {
11321 ...CLAUDE_TEST_MODEL_SELECTION,
11322 model: "claude-haiku-4-5-20251001",
11323 } satisfies ModelSelection;
11361 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
1132411362 const failedStart = yield* runtime
1132511363 .startTurn(
1132611364 makeClaudeTestTurnInput({
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+13
......@@ -246,6 +246,12 @@ type ClaudeOutboundFrame =
246246 | ClaudeQueryOpenFrame
247247 | ClaudePromptOfferFrame
248248 | ClaudeQuerySetModelFrame
249 | {
250 readonly type: "query.apply_flag_settings";
251 readonly settings: Parameters<
252 ClaudeAdapterV2.ClaudeAgentSdkQuerySession["applyFlagSettings"]
253 >[0];
254 }
249255 | ClaudeQuerySetPermissionModeFrame
250256 | ClaudeQueryInterruptFrame
251257 | ClaudePermissionResponseFrame
......@@ -819,6 +825,13 @@ function makeReplayQueryRunner(
819825 model,
820826 });
821827 }),
828 applyFlagSettings: (settings) =>
829 replayEffect(() => {
830 assertNextOutboundFrame({
831 type: "query.apply_flag_settings",
832 settings,
833 });
834 }),
822835 setPermissionMode: (mode) =>
823836 replayEffect(() => {
824837 assertNextOutboundFrame({
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+92-4
......@@ -18,6 +18,7 @@ import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsa
1818import {
1919 type CanUseTool,
2020 forkSession as forkClaudeSession,
21 type EffortLevel,
2122 type ForkSessionOptions,
2223 type ForkSessionResult,
2324 getSubagentMessages,
......@@ -109,7 +110,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";
109110import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts";
110111import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts";
111112import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
112import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";
113import {
114 compileClaudeModelSelection,
115 type CompiledClaudeModelSelection,
116} from "../../claudeModelOptions.ts";
113117import * as ServerConfig from "../../config.ts";
114118import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
115119import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
......@@ -414,6 +418,9 @@ export interface ClaudeAgentSdkQuerySession {
414418 readonly messages: Stream.Stream<SDKMessage, ClaudeAgentSdkQueryRunnerError>;
415419 readonly offer: (message: SDKUserMessage) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
416420 readonly setModel: (model: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
421 readonly applyFlagSettings: (
422 settings: ClaudeFlagSettings,
423 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
417424 readonly setPermissionMode: (
418425 mode: PermissionMode,
419426 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
......@@ -557,6 +564,14 @@ export type ClaudeAgentSdkProtocolLogEvent =
557564 readonly model: string;
558565 };
559566 }
567 | {
568 readonly direction: "outgoing";
569 readonly stage: "decoded";
570 readonly payload: {
571 readonly type: "query.apply_flag_settings";
572 readonly settings: ClaudeFlagSettings;
573 };
574 }
560575 | {
561576 readonly direction: "outgoing";
562577 readonly stage: "decoded";
......@@ -861,6 +876,22 @@ export const layerQueryRunner: Layer.Layer<
861876 }),
862877 ),
863878 ),
879 applyFlagSettings: (settings) =>
880 Effect.tryPromise({
881 try: () => queryRuntime.applyFlagSettings(settings),
882 catch: (cause) => queryRunnerError(cause, "applyFlagSettings"),
883 }).pipe(
884 Effect.tap(() =>
885 logProtocolEvent({
886 direction: "outgoing",
887 stage: "decoded",
888 payload: {
889 type: "query.apply_flag_settings",
890 settings,
891 },
892 }),
893 ),
894 ),
864895 setPermissionMode: (mode) =>
865896 Effect.tryPromise({
866897 try: () => queryRuntime.setPermissionMode(mode),
......@@ -993,6 +1024,37 @@ export const layerQueryRunner: Layer.Layer<
9931024 }),
9941025);
9951026
1027type ClaudeFlagSettings = Parameters<ClaudeQuery["applyFlagSettings"]>[0];
1028
1029/**
1030 * Thinking is a launch flag (`--thinking`), so only a new process can change
1031 * it. Clearing an explicit effort also needs one: the live reset goes to the
1032 * model default, while a new process reads the user's settings files.
1033 */
1034function canApplyClaudeSelectionLive(
1035 live: CompiledClaudeModelSelection,
1036 next: CompiledClaudeModelSelection,
1037): boolean {
1038 return (
1039 live.settings.alwaysThinkingEnabled === next.settings.alwaysThinkingEnabled &&
1040 (next.effort !== undefined || live.effort === undefined)
1041 );
1042}
1043
1044/**
1045 * Flag settings that move a live process to `selection`. `null` clears a key
1046 * a new process for `selection` would not set.
1047 */
1048function claudeLiveSelectionFlagSettings(
1049 selection: CompiledClaudeModelSelection,
1050): ClaudeFlagSettings {
1051 return {
1052 ...(selection.effort === undefined ? {} : { effortLevel: selection.effort as EffortLevel }),
1053 fastMode: selection.settings.fastMode ?? null,
1054 ultracode: selection.settings.ultracode ?? null,
1055 };
1056}
1057
9961058export function makeClaudeQueryOptions(input: {
9971059 readonly modelSelection: ModelSelection;
9981060 readonly nativeThreadId: string;
......@@ -3027,7 +3089,8 @@ interface ClaudeLiveQueryContext {
30273089 readonly nativeThreadId: string;
30283090 readonly query: ClaudeAgentSdkQuerySession;
30293091 readonly queryPolicyKey: string;
3030 readonly selectionKey: string;
3092 // The selection this process runs now. Live changes update it.
3093 selection: CompiledClaudeModelSelection;
30313094 readonly closed: Deferred.Deferred<void, never>;
30323095 // Retained for process-exit cleanup even when its last turn failed.
30333096 lastTurn: ActiveClaudeTurnContext | null;
......@@ -8351,11 +8414,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
83518414 ) {
83528415 return existing;
83538416 }
8417 // Model, effort, ultracode and fast mode change on the live process.
8418 // A restart would kill its background agents and shells.
8419 if (
8420 existing !== null &&
8421 existing.nativeThreadId === nativeThreadId &&
8422 existing.queryPolicyKey === queryPolicyKey &&
8423 !existing.stopping &&
8424 existing.selection.queryIdentity !== compiledSelection.queryIdentity &&
8425 canApplyClaudeSelectionLive(existing.selection, compiledSelection)
8426 ) {
8427 const live = existing;
8428 yield* live.query.setModel(compiledSelection.apiModelId).pipe(
8429 Effect.andThen(
8430 live.query.applyFlagSettings(claudeLiveSelectionFlagSettings(compiledSelection)),
8431 ),
8432 // A partial change leaves the process state unknown. A blank
8433 // identity matches no selection, so the next turn sends both again.
8434 Effect.tapError(() =>
8435 Effect.sync(() => {
8436 live.selection = { ...live.selection, queryIdentity: "" };
8437 }),
8438 ),
8439 );
8440 live.selection = compiledSelection;
8441 }
83548442 if (
83558443 existing !== null &&
83568444 existing.nativeThreadId === nativeThreadId &&
83578445 existing.queryPolicyKey === queryPolicyKey &&
8358 existing.selectionKey === compiledSelection.queryIdentity
8446 existing.selection.queryIdentity === compiledSelection.queryIdentity
83598447 ) {
83608448 // Claude can switch its own mode mid-session (EnterPlanMode), and
83618449 // a denied ExitPlanMode leaves it there. Put the live process back
......@@ -8482,7 +8570,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
84828570 nativeThreadId,
84838571 query: querySession,
84848572 queryPolicyKey,
8485 selectionKey: compiledSelection.queryIdentity,
8573 selection: compiledSelection,
84868574 closed,
84878575 lastTurn: null,
84888576 promptEchoMode: "unknown",
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+1
......@@ -159,6 +159,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
159159 }
160160 }),
161161 setModel: () => Effect.void,
162 applyFlagSettings: () => Effect.void,
162163 setPermissionMode: () => Effect.void,
163164 stopTask: () => Effect.void,
164165 interrupt: Effect.die("automatic delivery must never interrupt"),