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 = {...@@ -132,6 +132,12 @@ const CLAUDE_TEST_MODEL_SELECTION = {
132 model: "claude-sonnet-4-6",132 model: "claude-sonnet-4-6",
133 options: [{ id: "effort", value: "ultrathink" }],133 options: [{ id: "effort", value: "ultrathink" }],
134} satisfies ModelSelection;134} 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;
135const CLAUDE_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({141const CLAUDE_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
136 runtimeMode: "full-access",142 runtimeMode: "full-access",
137 interactionMode: "default",143 interactionMode: "default",
...@@ -1027,6 +1033,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {...@@ -1027,6 +1033,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
1027 messages: Stream.never,1033 messages: Stream.never,
1028 offer: () => Effect.void,1034 offer: () => Effect.void,
1029 setModel: () => Effect.void,1035 setModel: () => Effect.void,
1036 applyFlagSettings: () => Effect.void,
1030 setPermissionMode: () => Effect.void,1037 setPermissionMode: () => Effect.void,
1031 stopTask: () => Effect.void,1038 stopTask: () => Effect.void,
1032 interrupt: Effect.void,1039 interrupt: Effect.void,
...@@ -1187,6 +1194,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio...@@ -1187,6 +1194,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio
1187 messages: Stream.never,1194 messages: Stream.never,
1188 offer: () => Effect.void,1195 offer: () => Effect.void,
1189 setModel: () => Effect.void,1196 setModel: () => Effect.void,
1197 applyFlagSettings: () => Effect.void,
1190 setPermissionMode: () => Effect.void,1198 setPermissionMode: () => Effect.void,
1191 stopTask: () => Effect.void,1199 stopTask: () => Effect.void,
1192 interrupt: Effect.void,1200 interrupt: Effect.void,
...@@ -1307,6 +1315,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {...@@ -1307,6 +1315,7 @@ describe("ClaudeAdapterV2 resume compaction", () => {
1307 messages: Stream.never,1315 messages: Stream.never,
1308 offer: () => Effect.void,1316 offer: () => Effect.void,
1309 setModel: () => Effect.void,1317 setModel: () => Effect.void,
1318 applyFlagSettings: () => Effect.void,
1310 setPermissionMode: () => Effect.void,1319 setPermissionMode: () => Effect.void,
1311 stopTask: () => Effect.void,1320 stopTask: () => Effect.void,
1312 interrupt: Effect.void,1321 interrupt: Effect.void,
...@@ -1533,6 +1542,7 @@ describe("ClaudeAdapterV2 attachments", () => {...@@ -1533,6 +1542,7 @@ describe("ClaudeAdapterV2 attachments", () => {
1533 offeredMessages.push(message);1542 offeredMessages.push(message);
1534 }),1543 }),
1535 setModel: () => Effect.void,1544 setModel: () => Effect.void,
1545 applyFlagSettings: () => Effect.void,
1536 setPermissionMode: () => Effect.void,1546 setPermissionMode: () => Effect.void,
1537 stopTask: () => Effect.void,1547 stopTask: () => Effect.void,
1538 interrupt: Effect.void,1548 interrupt: Effect.void,
...@@ -1678,6 +1688,7 @@ describe("ClaudeAdapterV2 attachments", () => {...@@ -1678,6 +1688,7 @@ describe("ClaudeAdapterV2 attachments", () => {
1678 messages: Stream.never,1688 messages: Stream.never,
1679 offer: () => Effect.void,1689 offer: () => Effect.void,
1680 setModel: () => Effect.void,1690 setModel: () => Effect.void,
1691 applyFlagSettings: () => Effect.void,
1681 setPermissionMode: () => Effect.void,1692 setPermissionMode: () => Effect.void,
1682 stopTask: () => Effect.void,1693 stopTask: () => Effect.void,
1683 interrupt: Effect.void,1694 interrupt: Effect.void,
...@@ -1773,6 +1784,7 @@ describe("ClaudeAdapterV2 native fork", () => {...@@ -1773,6 +1784,7 @@ describe("ClaudeAdapterV2 native fork", () => {
1773 messages: Stream.empty,1784 messages: Stream.empty,
1774 offer: () => Effect.void,1785 offer: () => Effect.void,
1775 setModel: () => Effect.void,1786 setModel: () => Effect.void,
1787 applyFlagSettings: () => Effect.void,
1776 setPermissionMode: () => Effect.void,1788 setPermissionMode: () => Effect.void,
1777 stopTask: () => Effect.void,1789 stopTask: () => Effect.void,
1778 interrupt: Effect.void,1790 interrupt: Effect.void,
...@@ -2065,6 +2077,7 @@ describe("ClaudeAdapterV2 native session identity", () => {...@@ -2065,6 +2077,7 @@ describe("ClaudeAdapterV2 native session identity", () => {
2065 messages: Stream.empty,2077 messages: Stream.empty,
2066 offer: () => Effect.void,2078 offer: () => Effect.void,
2067 setModel: () => Effect.void,2079 setModel: () => Effect.void,
2080 applyFlagSettings: () => Effect.void,
2068 setPermissionMode: () => Effect.void,2081 setPermissionMode: () => Effect.void,
2069 stopTask: () => Effect.void,2082 stopTask: () => Effect.void,
2070 interrupt: Effect.void,2083 interrupt: Effect.void,
...@@ -2356,6 +2369,8 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2356,6 +2369,8 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2356 });2369 });
2357 const offeredMessages: Array<SDKUserMessage> = [];2370 const offeredMessages: Array<SDKUserMessage> = [];
2358 const permissionModeChanges: Array<string> = [];2371 const permissionModeChanges: Array<string> = [];
2372 // setModel and applyFlagSettings calls on the first process, in order.
2373 const liveSelectionChanges: Array<unknown> = [];
2359 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =2374 const continuationRequests: Array<ProviderContinuationRequests.ProviderContinuationRequest> =
2360 [];2375 [];
2361 const continuationReceipts =2376 const continuationReceipts =
...@@ -2403,6 +2418,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2403,6 +2418,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2403 offeredMessages.push(message);2418 offeredMessages.push(message);
2404 }),2419 }),
2405 setModel: () => Effect.void,2420 setModel: () => Effect.void,
2421 applyFlagSettings: () => Effect.void,
2406 setPermissionMode: () => Effect.void,2422 setPermissionMode: () => Effect.void,
2407 stopTask: options?.stopTask ?? (() => Effect.void),2423 stopTask: options?.stopTask ?? (() => Effect.void),
2408 interrupt: Effect.void,2424 interrupt: Effect.void,
...@@ -2432,7 +2448,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2432,7 +2448,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2432 Effect.sync(() => {2448 Effect.sync(() => {
2433 offeredMessages.push(message);2449 offeredMessages.push(message);
2434 }),2450 }),
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 }),
2436 setPermissionMode: (mode) =>2459 setPermissionMode: (mode) =>
2437 Effect.sync(() => {2460 Effect.sync(() => {
2438 permissionModeChanges.push(mode);2461 permissionModeChanges.push(mode);
...@@ -2503,6 +2526,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2503,6 +2526,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2503 return uuid;2526 return uuid;
2504 },2527 },
2505 permissionModeChanges,2528 permissionModeChanges,
2529 liveSelectionChanges,
2506 continuationRequests,2530 continuationRequests,
2507 continuationReceipts,2531 continuationReceipts,
2508 events,2532 events,
...@@ -2627,7 +2651,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2627,7 +2651,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2627 );2651 );
26282652
2629 it.effect(2653 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",
2631 () =>2655 () =>
2632 Effect.scoped(2656 Effect.scoped(
2633 Effect.gen(function* () {2657 Effect.gen(function* () {
...@@ -2648,41 +2672,65 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2648,41 +2672,65 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2648 providerTurnOrdinal: ordinal,2672 providerTurnOrdinal: ordinal,
2649 modelSelection,2673 modelSelection,
2650 });2674 });
2675 const settle = Effect.gen(function* () {
2676 yield* harness.offerAndWait(turnOneResult);
2677 yield* Queue.take(harness.terminalReceipts);
2678 });
2651 yield* harness.runtime.startTurn(turn(1, normal));2679 yield* harness.runtime.startTurn(turn(1, normal));
2652 const originalOptions = harness.getOpenedOptions();2680 const originalOptions = harness.getOpenedOptions();
2653 yield* harness.offerAndWait(wakeTaskStarted);2681 yield* harness.offerAndWait(wakeTaskStarted);
2654 yield* harness.offerAndWait(turnOneResult);2682 yield* settle;
2655 yield* Queue.take(harness.terminalReceipts);
2656 assert.isTrue(yield* harness.hasPendingBackgroundWork);2683 assert.isTrue(yield* harness.hasPendingBackgroundWork);
26572684
2685 // Omitted and explicit Normal are the same selection.
2658 yield* harness.runtime.startTurn(2686 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, {
2660 ...normal,2694 ...normal,
2661 options: [{ id: "fastMode", value: false }],2695 options: [
2696 { id: "effort", value: "ultracode" },
2697 { id: "fastMode", value: true },
2698 ],
2662 }),2699 }),
2663 );2700 );
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 ]);
2664 assert.strictEqual(harness.getOpenedOptions(), originalOptions);2711 assert.strictEqual(harness.getOpenedOptions(), originalOptions);
2665 assert.lengthOf(harness.offeredMessages, 2);2712 assert.lengthOf(harness.offeredMessages, 4);
2666 yield* harness.offerAndWait(turnOneResult);2713
2667 yield* Queue.take(harness.terminalReceipts);2714 // Thinking is a launch flag. Haiku 4.5 has no effort, and only a new
26682715 // process reads the user's configured effort again.
2669 const refused = yield* harness.runtime2716 for (const [ordinal, selection] of [
2670 .startTurn(2717 [5, CLAUDE_TEST_RESTART_MODEL_SELECTION],
2671 turn(3, {2718 [6, { ...normal, model: "claude-haiku-4-5-20251001" }],
2672 ...normal,2719 ] as const) {
2673 options: [{ id: "fastMode", value: true }],2720 const refused = yield* harness.runtime
2674 }),2721 .startTurn(turn(ordinal, selection))
2675 )2722 .pipe(Effect.result);
2676 .pipe(Effect.result);2723 assert.equal(refused._tag, "Failure");
2677 assert.equal(refused._tag, "Failure");2724 if (refused._tag === "Failure") {
2678 if (refused._tag === "Failure") {2725 assert.instanceOf(
2679 assert.instanceOf(2726 refused.failure.cause,
2680 refused.failure.cause,2727 ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError,
2681 ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError,2728 );
2682 );2729 }
2683 }2730 }
2684 assert.strictEqual(harness.getOpenedOptions(), originalOptions);2731 assert.strictEqual(harness.getOpenedOptions(), originalOptions);
2685 assert.lengthOf(harness.offeredMessages, 2);2732 assert.lengthOf(harness.offeredMessages, 4);
2733 assert.lengthOf(harness.liveSelectionChanges, 4);
2686 assert.isTrue(yield* harness.hasPendingBackgroundWork);2734 assert.isTrue(yield* harness.hasPendingBackgroundWork);
2687 }).pipe(2735 }).pipe(
2688 Effect.provide(2736 Effect.provide(
...@@ -4948,6 +4996,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4948,6 +4996,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4948 messages: Stream.fromQueue(sdkMessages),4996 messages: Stream.fromQueue(sdkMessages),
4949 offer: () => Effect.void,4997 offer: () => Effect.void,
4950 setModel: () => Effect.void,4998 setModel: () => Effect.void,
4999 applyFlagSettings: () => Effect.void,
4951 setPermissionMode: () => Effect.void,5000 setPermissionMode: () => Effect.void,
4952 stopTask: () => Effect.void,5001 stopTask: () => Effect.void,
4953 interrupt: Effect.void,5002 interrupt: Effect.void,
...@@ -5208,6 +5257,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -5208,6 +5257,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
5208 messages: Stream.fromQueue(queue),5257 messages: Stream.fromQueue(queue),
5209 offer: () => Effect.void,5258 offer: () => Effect.void,
5210 setModel: () => Effect.void,5259 setModel: () => Effect.void,
5260 applyFlagSettings: () => Effect.void,
5211 setPermissionMode: () => Effect.void,5261 setPermissionMode: () => Effect.void,
5212 stopTask: () => Effect.void,5262 stopTask: () => Effect.void,
5213 interrupt: Effect.void,5263 interrupt: Effect.void,
...@@ -10299,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10299,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10299 messages: Stream.fromQueue(sdkMessages),10349 messages: Stream.fromQueue(sdkMessages),
10300 offer: () => Effect.void,10350 offer: () => Effect.void,
10301 setModel: () => Effect.void,10351 setModel: () => Effect.void,
10352 applyFlagSettings: () => Effect.void,
10302 setPermissionMode: () => Effect.void,10353 setPermissionMode: () => Effect.void,
10303 stopTask: () => Effect.void,10354 stopTask: () => Effect.void,
10304 interrupt: Effect.void,10355 interrupt: Effect.void,
...@@ -10370,10 +10421,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10370,10 +10421,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10370 );10421 );
10371 assert.isFalse(yield* hasPendingBackgroundWork);10422 assert.isFalse(yield* hasPendingBackgroundWork);
1037210423
10373 const alternateModel = {10424 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
10374 ...CLAUDE_TEST_MODEL_SELECTION,
10375 model: "claude-haiku-4-5-20251001",
10376 } satisfies ModelSelection;
10377 // ProviderTurnStartService marks the thread active before startTurn;10425 // ProviderTurnStartService marks the thread active before startTurn;
10378 // the process-reset clear must preserve that status.10426 // the process-reset clear must preserve that status.
10379 const activeProviderThread = {10427 const activeProviderThread = {
...@@ -10489,6 +10537,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10489,6 +10537,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10489 messages: Stream.fromQueue(sdkMessages),10537 messages: Stream.fromQueue(sdkMessages),
10490 offer: () => Effect.void,10538 offer: () => Effect.void,
10491 setModel: () => Effect.void,10539 setModel: () => Effect.void,
10540 applyFlagSettings: () => Effect.void,
10492 setPermissionMode: () => Effect.void,10541 setPermissionMode: () => Effect.void,
10493 stopTask: () => Effect.void,10542 stopTask: () => Effect.void,
10494 interrupt: Effect.void,10543 interrupt: Effect.void,
...@@ -10556,10 +10605,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10556,10 +10605,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1055610605
10557 // User turn changes model, replacing the query while the wake buffer10606 // User turn changes model, replacing the query while the wake buffer
10558 // stays queued for the later provider continuation.10607 // stays queued for the later provider continuation.
10559 const alternateModel = {10608 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
10560 ...CLAUDE_TEST_MODEL_SELECTION,
10561 model: "claude-haiku-4-5-20251001",
10562 } satisfies ModelSelection;
10563 yield* runtime.startTurn(10609 yield* runtime.startTurn(
10564 makeClaudeTestTurnInput({10610 makeClaudeTestTurnInput({
10565 threadId,10611 threadId,
...@@ -10731,6 +10777,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10731,6 +10777,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10731 messages: Stream.fromQueue(sdkMessages),10777 messages: Stream.fromQueue(sdkMessages),
10732 offer: () => Effect.void,10778 offer: () => Effect.void,
10733 setModel: () => Effect.void,10779 setModel: () => Effect.void,
10780 applyFlagSettings: () => Effect.void,
10734 setPermissionMode: () => Effect.void,10781 setPermissionMode: () => Effect.void,
10735 stopTask: () => Effect.void,10782 stopTask: () => Effect.void,
10736 interrupt: Effect.void,10783 interrupt: Effect.void,
...@@ -10818,10 +10865,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10818,10 +10865,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10818 // Model-changing user turn replaces the query while continuation stays10865 // Model-changing user turn replaces the query while continuation stays
10819 // queued. Process reset must not invent opaque classification for the10866 // queued. Process reset must not invent opaque classification for the
10820 // buffered subagent notification.10867 // buffered subagent notification.
10821 const alternateModel = {10868 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
10822 ...CLAUDE_TEST_MODEL_SELECTION,
10823 model: "claude-haiku-4-5-20251001",
10824 } satisfies ModelSelection;
10825 yield* runtime.startTurn(10869 yield* runtime.startTurn(
10826 makeClaudeTestTurnInput({10870 makeClaudeTestTurnInput({
10827 threadId,10871 threadId,
...@@ -10900,7 +10944,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10900,7 +10944,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10900 ),10944 ),
10901 );10945 );
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", () =>
10904 Effect.scoped(10948 Effect.scoped(
10905 Effect.gen(function* () {10949 Effect.gen(function* () {
10906 const SUBAGENT_TASK_ID = "task-model-change-running-subagent";10950 const SUBAGENT_TASK_ID = "task-model-change-running-subagent";
...@@ -10932,6 +10976,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10932,6 +10976,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10932 messages: Stream.fromQueue(sdkMessages),10976 messages: Stream.fromQueue(sdkMessages),
10933 offer: () => Effect.void,10977 offer: () => Effect.void,
10934 setModel: () => Effect.void,10978 setModel: () => Effect.void,
10979 applyFlagSettings: () => Effect.void,
10935 setPermissionMode: () => Effect.void,10980 setPermissionMode: () => Effect.void,
10936 stopTask: () => Effect.void,10981 stopTask: () => Effect.void,
10937 interrupt: Effect.void,10982 interrupt: Effect.void,
...@@ -11004,12 +11049,9 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11004,12 +11049,9 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11004 yield* awaitUntil(() => terminals().length === 1, "first turn terminal");11049 yield* awaitUntil(() => terminals().length === 1, "first turn terminal");
11005 const settledTurn = terminals()[0]!;11050 const settledTurn = terminals()[0]!;
1100611051
11007 // The subagent runs inside the first CLI process. Another model needs11052 // The subagent runs inside the first CLI process. A thinking change
11008 // another process, so the turn must not start and close this one.11053 // needs another process, so the turn must not start and close this one.
11009 const alternateModel = {11054 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
11010 ...CLAUDE_TEST_MODEL_SELECTION,
11011 model: "claude-haiku-4-5-20251001",
11012 } satisfies ModelSelection;
11013 const switchTurn = (attempt: string) =>11055 const switchTurn = (attempt: string) =>
11014 runtime.startTurn(11056 runtime.startTurn(
11015 makeClaudeTestTurnInput({11057 makeClaudeTestTurnInput({
...@@ -11115,6 +11157,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11115,6 +11157,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11115 messages: Stream.fromQueue(sdkMessages),11157 messages: Stream.fromQueue(sdkMessages),
11116 offer: () => Effect.void,11158 offer: () => Effect.void,
11117 setModel: () => Effect.void,11159 setModel: () => Effect.void,
11160 applyFlagSettings: () => Effect.void,
11118 setPermissionMode: () => Effect.void,11161 setPermissionMode: () => Effect.void,
11119 stopTask: () => Effect.void,11162 stopTask: () => Effect.void,
11120 interrupt: Effect.void,11163 interrupt: Effect.void,
...@@ -11174,10 +11217,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11174,10 +11217,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11174 );11217 );
11175 assert.isTrue(yield* hasPendingBackgroundWork);11218 assert.isTrue(yield* hasPendingBackgroundWork);
1117611219
11177 const alternateModel = {11220 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
11178 ...CLAUDE_TEST_MODEL_SELECTION,
11179 model: "claude-haiku-4-5-20251001",
11180 } satisfies ModelSelection;
11181 const failedStart = yield* runtime11221 const failedStart = yield* runtime
11182 .startTurn(11222 .startTurn(
11183 makeClaudeTestTurnInput({11223 makeClaudeTestTurnInput({
...@@ -11254,6 +11294,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11254,6 +11294,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11254 messages: Stream.fromQueue(sdkMessages),11294 messages: Stream.fromQueue(sdkMessages),
11255 offer: () => Effect.void,11295 offer: () => Effect.void,
11256 setModel: () => Effect.void,11296 setModel: () => Effect.void,
11297 applyFlagSettings: () => Effect.void,
11257 setPermissionMode: () => Effect.void,11298 setPermissionMode: () => Effect.void,
11258 stopTask: () => Effect.void,11299 stopTask: () => Effect.void,
11259 interrupt: Effect.void,11300 interrupt: Effect.void,
...@@ -11317,10 +11358,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11317,10 +11358,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11317 yield* awaitUntil(() => continuationRequests.length === 1, "first continuation request");11358 yield* awaitUntil(() => continuationRequests.length === 1, "first continuation request");
11318 assert.isTrue(yield* hasPendingBackgroundWork);11359 assert.isTrue(yield* hasPendingBackgroundWork);
1131911360
11320 const alternateModel = {11361 const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
11321 ...CLAUDE_TEST_MODEL_SELECTION,
11322 model: "claude-haiku-4-5-20251001",
11323 } satisfies ModelSelection;
11324 const failedStart = yield* runtime11362 const failedStart = yield* runtime
11325 .startTurn(11363 .startTurn(
11326 makeClaudeTestTurnInput({11364 makeClaudeTestTurnInput({
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+13
...@@ -246,6 +246,12 @@ type ClaudeOutboundFrame =...@@ -246,6 +246,12 @@ type ClaudeOutboundFrame =
246 | ClaudeQueryOpenFrame246 | ClaudeQueryOpenFrame
247 | ClaudePromptOfferFrame247 | ClaudePromptOfferFrame
248 | ClaudeQuerySetModelFrame248 | ClaudeQuerySetModelFrame
249 | {
250 readonly type: "query.apply_flag_settings";
251 readonly settings: Parameters<
252 ClaudeAdapterV2.ClaudeAgentSdkQuerySession["applyFlagSettings"]
253 >[0];
254 }
249 | ClaudeQuerySetPermissionModeFrame255 | ClaudeQuerySetPermissionModeFrame
250 | ClaudeQueryInterruptFrame256 | ClaudeQueryInterruptFrame
251 | ClaudePermissionResponseFrame257 | ClaudePermissionResponseFrame
...@@ -819,6 +825,13 @@ function makeReplayQueryRunner(...@@ -819,6 +825,13 @@ function makeReplayQueryRunner(
819 model,825 model,
820 });826 });
821 }),827 }),
828 applyFlagSettings: (settings) =>
829 replayEffect(() => {
830 assertNextOutboundFrame({
831 type: "query.apply_flag_settings",
832 settings,
833 });
834 }),
822 setPermissionMode: (mode) =>835 setPermissionMode: (mode) =>
823 replayEffect(() => {836 replayEffect(() => {
824 assertNextOutboundFrame({837 assertNextOutboundFrame({
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+92-4
...@@ -18,6 +18,7 @@ import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsa...@@ -18,6 +18,7 @@ import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsa
18import {18import {
19 type CanUseTool,19 type CanUseTool,
20 forkSession as forkClaudeSession,20 forkSession as forkClaudeSession,
21 type EffortLevel,
21 type ForkSessionOptions,22 type ForkSessionOptions,
22 type ForkSessionResult,23 type ForkSessionResult,
23 getSubagentMessages,24 getSubagentMessages,
...@@ -109,7 +110,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";...@@ -109,7 +110,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";
109import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts";110import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts";
110import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts";111import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts";
111import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";112import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts";
112import { compileClaudeModelSelection } from "../../claudeModelOptions.ts";113import {
114 compileClaudeModelSelection,
115 type CompiledClaudeModelSelection,
116} from "../../claudeModelOptions.ts";
113import * as ServerConfig from "../../config.ts";117import * as ServerConfig from "../../config.ts";
114import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";118import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
115import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";119import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
...@@ -414,6 +418,9 @@ export interface ClaudeAgentSdkQuerySession {...@@ -414,6 +418,9 @@ export interface ClaudeAgentSdkQuerySession {
414 readonly messages: Stream.Stream<SDKMessage, ClaudeAgentSdkQueryRunnerError>;418 readonly messages: Stream.Stream<SDKMessage, ClaudeAgentSdkQueryRunnerError>;
415 readonly offer: (message: SDKUserMessage) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;419 readonly offer: (message: SDKUserMessage) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
416 readonly setModel: (model: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;420 readonly setModel: (model: string) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
421 readonly applyFlagSettings: (
422 settings: ClaudeFlagSettings,
423 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
417 readonly setPermissionMode: (424 readonly setPermissionMode: (
418 mode: PermissionMode,425 mode: PermissionMode,
419 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;426 ) => Effect.Effect<void, ClaudeAgentSdkQueryRunnerError>;
...@@ -557,6 +564,14 @@ export type ClaudeAgentSdkProtocolLogEvent =...@@ -557,6 +564,14 @@ export type ClaudeAgentSdkProtocolLogEvent =
557 readonly model: string;564 readonly model: string;
558 };565 };
559 }566 }
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 }
560 | {575 | {
561 readonly direction: "outgoing";576 readonly direction: "outgoing";
562 readonly stage: "decoded";577 readonly stage: "decoded";
...@@ -861,6 +876,22 @@ export const layerQueryRunner: Layer.Layer<...@@ -861,6 +876,22 @@ export const layerQueryRunner: Layer.Layer<
861 }),876 }),
862 ),877 ),
863 ),878 ),
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 ),
864 setPermissionMode: (mode) =>895 setPermissionMode: (mode) =>
865 Effect.tryPromise({896 Effect.tryPromise({
866 try: () => queryRuntime.setPermissionMode(mode),897 try: () => queryRuntime.setPermissionMode(mode),
...@@ -993,6 +1024,37 @@ export const layerQueryRunner: Layer.Layer<...@@ -993,6 +1024,37 @@ export const layerQueryRunner: Layer.Layer<
993 }),1024 }),
994);1025);
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
996export function makeClaudeQueryOptions(input: {1058export function makeClaudeQueryOptions(input: {
997 readonly modelSelection: ModelSelection;1059 readonly modelSelection: ModelSelection;
998 readonly nativeThreadId: string;1060 readonly nativeThreadId: string;
...@@ -3027,7 +3089,8 @@ interface ClaudeLiveQueryContext {...@@ -3027,7 +3089,8 @@ interface ClaudeLiveQueryContext {
3027 readonly nativeThreadId: string;3089 readonly nativeThreadId: string;
3028 readonly query: ClaudeAgentSdkQuerySession;3090 readonly query: ClaudeAgentSdkQuerySession;
3029 readonly queryPolicyKey: string;3091 readonly queryPolicyKey: string;
3030 readonly selectionKey: string;3092 // The selection this process runs now. Live changes update it.
3093 selection: CompiledClaudeModelSelection;
3031 readonly closed: Deferred.Deferred<void, never>;3094 readonly closed: Deferred.Deferred<void, never>;
3032 // Retained for process-exit cleanup even when its last turn failed.3095 // Retained for process-exit cleanup even when its last turn failed.
3033 lastTurn: ActiveClaudeTurnContext | null;3096 lastTurn: ActiveClaudeTurnContext | null;
...@@ -8351,11 +8414,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8351,11 +8414,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8351 ) {8414 ) {
8352 return existing;8415 return existing;
8353 }8416 }
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 }
8354 if (8442 if (
8355 existing !== null &&8443 existing !== null &&
8356 existing.nativeThreadId === nativeThreadId &&8444 existing.nativeThreadId === nativeThreadId &&
8357 existing.queryPolicyKey === queryPolicyKey &&8445 existing.queryPolicyKey === queryPolicyKey &&
8358 existing.selectionKey === compiledSelection.queryIdentity8446 existing.selection.queryIdentity === compiledSelection.queryIdentity
8359 ) {8447 ) {
8360 // Claude can switch its own mode mid-session (EnterPlanMode), and8448 // Claude can switch its own mode mid-session (EnterPlanMode), and
8361 // a denied ExitPlanMode leaves it there. Put the live process back8449 // a denied ExitPlanMode leaves it there. Put the live process back
...@@ -8482,7 +8570,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8482,7 +8570,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8482 nativeThreadId,8570 nativeThreadId,
8483 query: querySession,8571 query: querySession,
8484 queryPolicyKey,8572 queryPolicyKey,
8485 selectionKey: compiledSelection.queryIdentity,8573 selection: compiledSelection,
8486 closed,8574 closed,
8487 lastTurn: null,8575 lastTurn: null,
8488 promptEchoMode: "unknown",8576 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...@@ -159,6 +159,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
159 }159 }
160 }),160 }),
161 setModel: () => Effect.void,161 setModel: () => Effect.void,
162 applyFlagSettings: () => Effect.void,
162 setPermissionMode: () => Effect.void,163 setPermissionMode: () => Effect.void,
163 stopTask: () => Effect.void,164 stopTask: () => Effect.void,
164 interrupt: Effect.die("automatic delivery must never interrupt"),165 interrupt: Effect.die("automatic delivery must never interrupt"),