diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 648817534ef7c0b4d9846e6700b14267902aaf1e..ec65e9a5ac5ced3219bfd20a343b987e33339c51 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -132,6 +132,12 @@ const CLAUDE_TEST_MODEL_SELECTION = { model: "claude-sonnet-4-6", options: [{ id: "effort", value: "ultrathink" }], } satisfies ModelSelection; +// Thinking is a launch flag, so switching to this selection needs a new process. +const CLAUDE_TEST_RESTART_MODEL_SELECTION = { + ...CLAUDE_TEST_MODEL_SELECTION, + model: "claude-haiku-4-5-20251001", + options: [{ id: "thinking", value: false }], +} satisfies ModelSelection; const CLAUDE_TEST_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "full-access", interactionMode: "default", @@ -1027,6 +1033,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -1187,6 +1194,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -1307,6 +1315,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -1533,6 +1542,7 @@ describe("ClaudeAdapterV2 attachments", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -1678,6 +1688,7 @@ describe("ClaudeAdapterV2 attachments", () => { messages: Stream.never, offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -1773,6 +1784,7 @@ describe("ClaudeAdapterV2 native fork", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -2065,6 +2077,7 @@ describe("ClaudeAdapterV2 native session identity", () => { messages: Stream.empty, offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -2356,6 +2369,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const offeredMessages: Array = []; const permissionModeChanges: Array = []; + // setModel and applyFlagSettings calls on the first process, in order. + const liveSelectionChanges: Array = []; const continuationRequests: Array = []; const continuationReceipts = @@ -2403,6 +2418,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offeredMessages.push(message); }), setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: options?.stopTask ?? (() => Effect.void), interrupt: Effect.void, @@ -2432,7 +2448,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.sync(() => { offeredMessages.push(message); }), - setModel: () => Effect.void, + setModel: (model) => + Effect.sync(() => { + liveSelectionChanges.push({ model }); + }), + applyFlagSettings: (settings) => + Effect.sync(() => { + liveSelectionChanges.push(settings); + }), setPermissionMode: (mode) => Effect.sync(() => { permissionModeChanges.push(mode); @@ -2503,6 +2526,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { return uuid; }, permissionModeChanges, + liveSelectionChanges, continuationRequests, continuationReceipts, events, @@ -2627,7 +2651,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect( - "reuses a background shell's query for omitted and explicit Normal, but blocks Fast", + "applies model, effort and Fast changes to a process running a background shell, but not thinking or a cleared effort", () => Effect.scoped( Effect.gen(function* () { @@ -2648,41 +2672,65 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: ordinal, modelSelection, }); + const settle = Effect.gen(function* () { + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + }); yield* harness.runtime.startTurn(turn(1, normal)); const originalOptions = harness.getOpenedOptions(); yield* harness.offerAndWait(wakeTaskStarted); - yield* harness.offerAndWait(turnOneResult); - yield* Queue.take(harness.terminalReceipts); + yield* settle; assert.isTrue(yield* harness.hasPendingBackgroundWork); + // Omitted and explicit Normal are the same selection. yield* harness.runtime.startTurn( - turn(2, { + turn(2, { ...normal, options: [{ id: "fastMode", value: false }] }), + ); + yield* settle; + assert.deepEqual(harness.liveSelectionChanges, []); + + yield* harness.runtime.startTurn( + turn(3, { ...normal, - options: [{ id: "fastMode", value: false }], + options: [ + { id: "effort", value: "ultracode" }, + { id: "fastMode", value: true }, + ], }), ); + yield* settle; + yield* harness.runtime.startTurn(turn(4, { ...normal, model: "claude-sonnet-4-6" })); + yield* settle; + assert.deepEqual(harness.liveSelectionChanges, [ + { model: "claude-opus-5-5" }, + { effortLevel: "xhigh", fastMode: true, ultracode: true }, + { model: "claude-sonnet-4-6" }, + // Sonnet's default effort; it has no Fast mode. + { effortLevel: "high", fastMode: null, ultracode: null }, + ]); assert.strictEqual(harness.getOpenedOptions(), originalOptions); - assert.lengthOf(harness.offeredMessages, 2); - yield* harness.offerAndWait(turnOneResult); - yield* Queue.take(harness.terminalReceipts); + assert.lengthOf(harness.offeredMessages, 4); - const refused = yield* harness.runtime - .startTurn( - turn(3, { - ...normal, - options: [{ id: "fastMode", value: true }], - }), - ) - .pipe(Effect.result); - assert.equal(refused._tag, "Failure"); - if (refused._tag === "Failure") { - assert.instanceOf( - refused.failure.cause, - ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError, - ); + // Thinking is a launch flag. Haiku 4.5 has no effort, and only a new + // process reads the user's configured effort again. + for (const [ordinal, selection] of [ + [5, CLAUDE_TEST_RESTART_MODEL_SELECTION], + [6, { ...normal, model: "claude-haiku-4-5-20251001" }], + ] as const) { + const refused = yield* harness.runtime + .startTurn(turn(ordinal, selection)) + .pipe(Effect.result); + assert.equal(refused._tag, "Failure"); + if (refused._tag === "Failure") { + assert.instanceOf( + refused.failure.cause, + ClaudeAdapterV2.ClaudeBackgroundWorkBlocksQueryReplacementError, + ); + } } assert.strictEqual(harness.getOpenedOptions(), originalOptions); - assert.lengthOf(harness.offeredMessages, 2); + assert.lengthOf(harness.offeredMessages, 4); + assert.lengthOf(harness.liveSelectionChanges, 4); assert.isTrue(yield* harness.hasPendingBackgroundWork); }).pipe( Effect.provide( @@ -4948,6 +4996,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -5208,6 +5257,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(queue), offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -10299,6 +10349,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -10370,10 +10421,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.isFalse(yield* hasPendingBackgroundWork); - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; + const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; // ProviderTurnStartService marks the thread active before startTurn; // the process-reset clear must preserve that status. const activeProviderThread = { @@ -10489,6 +10537,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -10556,10 +10605,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { // User turn changes model, replacing the query while the wake buffer // stays queued for the later provider continuation. - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; + const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; yield* runtime.startTurn( makeClaudeTestTurnInput({ threadId, @@ -10731,6 +10777,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -10818,10 +10865,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { // Model-changing user turn replaces the query while continuation stays // queued. Process reset must not invent opaque classification for the // buffered subagent notification. - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; + const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; yield* runtime.startTurn( makeClaudeTestTurnInput({ threadId, @@ -10900,7 +10944,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("refuses a model change that would kill a running background subagent", () => + it.effect("refuses a thinking change that would kill a running background subagent", () => Effect.scoped( Effect.gen(function* () { const SUBAGENT_TASK_ID = "task-model-change-running-subagent"; @@ -10932,6 +10976,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -11004,12 +11049,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => terminals().length === 1, "first turn terminal"); const settledTurn = terminals()[0]!; - // The subagent runs inside the first CLI process. Another model needs - // another process, so the turn must not start and close this one. - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; + // The subagent runs inside the first CLI process. A thinking change + // needs another process, so the turn must not start and close this one. + const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; const switchTurn = (attempt: string) => runtime.startTurn( makeClaudeTestTurnInput({ @@ -11115,6 +11157,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -11174,10 +11217,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.isTrue(yield* hasPendingBackgroundWork); - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; + const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; const failedStart = yield* runtime .startTurn( makeClaudeTestTurnInput({ @@ -11254,6 +11294,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messages: Stream.fromQueue(sdkMessages), offer: () => Effect.void, setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.void, @@ -11317,10 +11358,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => continuationRequests.length === 1, "first continuation request"); assert.isTrue(yield* hasPendingBackgroundWork); - const alternateModel = { - ...CLAUDE_TEST_MODEL_SELECTION, - model: "claude-haiku-4-5-20251001", - } satisfies ModelSelection; + const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION; const failedStart = yield* runtime .startTurn( makeClaudeTestTurnInput({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 7680b250bb508873860b94da132dd5a5c83bab1c..c8fa9e25b94408195d9e862c4200c22654b51f3b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -246,6 +246,12 @@ type ClaudeOutboundFrame = | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame + | { + readonly type: "query.apply_flag_settings"; + readonly settings: Parameters< + ClaudeAdapterV2.ClaudeAgentSdkQuerySession["applyFlagSettings"] + >[0]; + } | ClaudeQuerySetPermissionModeFrame | ClaudeQueryInterruptFrame | ClaudePermissionResponseFrame @@ -819,6 +825,13 @@ function makeReplayQueryRunner( model, }); }), + applyFlagSettings: (settings) => + replayEffect(() => { + assertNextOutboundFrame({ + type: "query.apply_flag_settings", + settings, + }); + }), setPermissionMode: (mode) => replayEffect(() => { assertNextOutboundFrame({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1dcfb20723b3e2eb57c95af102f688e2a74fcc6a..6c3c290096ba4368ea0c2db0399ba3bcac0fa83b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -18,6 +18,7 @@ import { normalizeClaudeTurnTokenUsage } from "../../provider/ClaudeTurnTokenUsa import { type CanUseTool, forkSession as forkClaudeSession, + type EffortLevel, type ForkSessionOptions, type ForkSessionResult, getSubagentMessages, @@ -109,7 +110,10 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts"; import { planClaudeSkillDispatch } from "../../provider/Drivers/ClaudeSkillDispatch.ts"; import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; -import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; +import { + compileClaudeModelSelection, + type CompiledClaudeModelSelection, +} from "../../claudeModelOptions.ts"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; @@ -414,6 +418,9 @@ export interface ClaudeAgentSdkQuerySession { readonly messages: Stream.Stream; readonly offer: (message: SDKUserMessage) => Effect.Effect; readonly setModel: (model: string) => Effect.Effect; + readonly applyFlagSettings: ( + settings: ClaudeFlagSettings, + ) => Effect.Effect; readonly setPermissionMode: ( mode: PermissionMode, ) => Effect.Effect; @@ -557,6 +564,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly model: string; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.apply_flag_settings"; + readonly settings: ClaudeFlagSettings; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -861,6 +876,22 @@ export const layerQueryRunner: Layer.Layer< }), ), ), + applyFlagSettings: (settings) => + Effect.tryPromise({ + try: () => queryRuntime.applyFlagSettings(settings), + catch: (cause) => queryRunnerError(cause, "applyFlagSettings"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "query.apply_flag_settings", + settings, + }, + }), + ), + ), setPermissionMode: (mode) => Effect.tryPromise({ try: () => queryRuntime.setPermissionMode(mode), @@ -993,6 +1024,37 @@ export const layerQueryRunner: Layer.Layer< }), ); +type ClaudeFlagSettings = Parameters[0]; + +/** + * Thinking is a launch flag (`--thinking`), so only a new process can change + * it. Clearing an explicit effort also needs one: the live reset goes to the + * model default, while a new process reads the user's settings files. + */ +function canApplyClaudeSelectionLive( + live: CompiledClaudeModelSelection, + next: CompiledClaudeModelSelection, +): boolean { + return ( + live.settings.alwaysThinkingEnabled === next.settings.alwaysThinkingEnabled && + (next.effort !== undefined || live.effort === undefined) + ); +} + +/** + * Flag settings that move a live process to `selection`. `null` clears a key + * a new process for `selection` would not set. + */ +function claudeLiveSelectionFlagSettings( + selection: CompiledClaudeModelSelection, +): ClaudeFlagSettings { + return { + ...(selection.effort === undefined ? {} : { effortLevel: selection.effort as EffortLevel }), + fastMode: selection.settings.fastMode ?? null, + ultracode: selection.settings.ultracode ?? null, + }; +} + export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly nativeThreadId: string; @@ -3027,7 +3089,8 @@ interface ClaudeLiveQueryContext { readonly nativeThreadId: string; readonly query: ClaudeAgentSdkQuerySession; readonly queryPolicyKey: string; - readonly selectionKey: string; + // The selection this process runs now. Live changes update it. + selection: CompiledClaudeModelSelection; readonly closed: Deferred.Deferred; // Retained for process-exit cleanup even when its last turn failed. lastTurn: ActiveClaudeTurnContext | null; @@ -8351,11 +8414,36 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return existing; } + // Model, effort, ultracode and fast mode change on the live process. + // A restart would kill its background agents and shells. if ( existing !== null && existing.nativeThreadId === nativeThreadId && existing.queryPolicyKey === queryPolicyKey && - existing.selectionKey === compiledSelection.queryIdentity + !existing.stopping && + existing.selection.queryIdentity !== compiledSelection.queryIdentity && + canApplyClaudeSelectionLive(existing.selection, compiledSelection) + ) { + const live = existing; + yield* live.query.setModel(compiledSelection.apiModelId).pipe( + Effect.andThen( + live.query.applyFlagSettings(claudeLiveSelectionFlagSettings(compiledSelection)), + ), + // A partial change leaves the process state unknown. A blank + // identity matches no selection, so the next turn sends both again. + Effect.tapError(() => + Effect.sync(() => { + live.selection = { ...live.selection, queryIdentity: "" }; + }), + ), + ); + live.selection = compiledSelection; + } + if ( + existing !== null && + existing.nativeThreadId === nativeThreadId && + existing.queryPolicyKey === queryPolicyKey && + existing.selection.queryIdentity === compiledSelection.queryIdentity ) { // Claude can switch its own mode mid-session (EnterPlanMode), and // a denied ExitPlanMode leaves it there. Put the live process back @@ -8482,7 +8570,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId, query: querySession, queryPolicyKey, - selectionKey: compiledSelection.queryIdentity, + selection: compiledSelection, closed, lastTurn: null, promptEchoMode: "unknown", diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index 279c9bced95ee712dd455632bf55b9417005d91d..3fd034abe4f27c5de884126577f5679596b57f32 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -159,6 +159,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con } }), setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, stopTask: () => Effect.void, interrupt: Effect.die("automatic delivery must never interrupt"),