diff --git a/src/features/chat/application/runtime/runtime-settings-commit-coordinator.ts b/src/features/chat/application/runtime/runtime-settings-commit-coordinator.ts new file mode 100644 index 00000000..77293e9f --- /dev/null +++ b/src/features/chat/application/runtime/runtime-settings-commit-coordinator.ts @@ -0,0 +1,192 @@ +import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; + +export type RuntimeSettingsCommitTarget = { readonly kind: "settle" } | { readonly kind: "fields"; readonly update: RuntimeSettingsPatch }; + +export interface RuntimeSettingsCommitCoordinatorHost { + activeThreadId: () => string | null; + pendingPatch: () => RuntimeSettingsPatch; + updateThreadSettings: (threadId: string, update: RuntimeSettingsPatch) => Promise; + commitPatch: (update: RuntimeSettingsPatch) => void; + reportError: (error: unknown) => void; +} + +export interface RuntimeSettingsCommitCoordinator { + commit: (threadId: string, target: RuntimeSettingsCommitTarget) => Promise; +} + +interface PendingCommit { + readonly kind: RuntimeSettingsCommitTarget["kind"]; + remaining: RuntimeSettingsPatch; + resolve: (ok: boolean) => void; +} + +interface ThreadCommitDrain { + revision: number; + readonly pending: PendingCommit[]; +} + +export function createRuntimeSettingsCommitCoordinator(host: RuntimeSettingsCommitCoordinatorHost): RuntimeSettingsCommitCoordinator { + const drains = new Map(); + + return { + commit: (threadId, target) => { + let drain = drains.get(threadId); + if (!drain) { + drain = { revision: 0, pending: [] }; + drains.set(threadId, drain); + } + drain.revision += 1; + const result = new Promise((resolve) => { + drain.pending.push({ + kind: target.kind, + remaining: target.kind === "fields" ? { ...target.update } : {}, + resolve, + }); + }); + if (drain.pending.length === 1) void runThreadCommitDrain(host, threadId, drain, drains); + return result; + }, + }; +} + +async function runThreadCommitDrain( + host: RuntimeSettingsCommitCoordinatorHost, + threadId: string, + drain: ThreadCommitDrain, + drains: Map, +): Promise { + try { + while (drain.pending.length > 0) { + if (host.activeThreadId() !== threadId) { + resolveAll(drain, false); + return; + } + + const update = host.pendingPatch(); + if (patchEmpty(update)) { + resolveSettledCommits(drain); + return; + } + + const sentRevision = drain.revision; + let updated: boolean; + try { + updated = await host.updateThreadSettings(threadId, update); + } catch (error) { + if (host.activeThreadId() === threadId) host.reportError(error); + resolveAll(drain, false); + return; + } + + if (host.activeThreadId() !== threadId) { + resolveAll(drain, false); + return; + } + if (!updated) { + resolveAll(drain, false); + return; + } + + const committed = matchingPendingPatch(host.pendingPatch(), update); + if (!patchEmpty(committed)) host.commitPatch(committed); + resolveFieldCommits(drain, committed, host.pendingPatch()); + + if (drain.pending.length === 0) return; + if (patchEmpty(host.pendingPatch())) { + resolveSettledCommits(drain); + return; + } + if (drain.revision !== sentRevision || drain.pending.some((pending) => pending.kind === "settle")) { + continue; + } + + resolveAll(drain, false); + return; + } + } finally { + if (drains.get(threadId) === drain) drains.delete(threadId); + } +} + +function matchingPendingPatch(current: RuntimeSettingsPatch, sent: RuntimeSettingsPatch): RuntimeSettingsPatch { + const matching: RuntimeSettingsPatch = {}; + for (const key of patchKeys(sent)) { + if (key in current && threadSettingsValueEqual(current[key], sent[key])) { + Object.assign(matching, { [key]: sent[key] }); + } + } + return matching; +} + +function resolveFieldCommits(drain: ThreadCommitDrain, committed: RuntimeSettingsPatch, current: RuntimeSettingsPatch): void { + for (const pending of [...drain.pending]) { + if (pending.kind === "settle") continue; + for (const key of patchKeys(pending.remaining)) { + if (key in committed && threadSettingsValueEqual(pending.remaining[key], committed[key])) { + pending.remaining = patchWithoutField(pending.remaining, key); + } + } + if (patchEmpty(pending.remaining)) { + resolveCommit(drain, pending, true); + continue; + } + if (patchKeys(pending.remaining).some((key) => !(key in current) || !threadSettingsValueEqual(pending.remaining[key], current[key]))) { + resolveCommit(drain, pending, false); + } + } +} + +function resolveSettledCommits(drain: ThreadCommitDrain): void { + for (const pending of [...drain.pending]) { + resolveCommit(drain, pending, pending.kind === "settle" || patchEmpty(pending.remaining)); + } +} + +function resolveAll(drain: ThreadCommitDrain, ok: boolean): void { + for (const pending of [...drain.pending]) resolveCommit(drain, pending, ok); +} + +function resolveCommit(drain: ThreadCommitDrain, pending: PendingCommit, ok: boolean): void { + const index = drain.pending.indexOf(pending); + if (index >= 0) drain.pending.splice(index, 1); + pending.resolve(ok); +} + +function patchKeys(patch: RuntimeSettingsPatch): (keyof RuntimeSettingsPatch)[] { + return Object.keys(patch) as (keyof RuntimeSettingsPatch)[]; +} + +function patchEmpty(patch: RuntimeSettingsPatch): boolean { + return patchKeys(patch).length === 0; +} + +function patchWithoutField(patch: RuntimeSettingsPatch, omittedKey: keyof RuntimeSettingsPatch): RuntimeSettingsPatch { + const remaining: RuntimeSettingsPatch = {}; + for (const key of patchKeys(patch)) { + if (key !== omittedKey) Object.assign(remaining, { [key]: patch[key] }); + } + return remaining; +} + +function threadSettingsValueEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((value, index) => threadSettingsValueEqual(value, right[index])) + ); + } + if (!isPlainRecord(left) || !isPlainRecord(right)) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key) => Object.hasOwn(right, key) && threadSettingsValueEqual(left[key], right[key])) + ); +} + +function isPlainRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index 79f13805..c7a9d9c1 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -12,6 +12,11 @@ import { import { type ActivePanelOperation, activePanelOperationDecision } from "../panel-operation-policy"; import { activeThreadId, type ChatAction, type ChatState } from "../state/root-reducer"; import type { ChatStateStore } from "../state/store"; +import { + createRuntimeSettingsCommitCoordinator, + type RuntimeSettingsCommitCoordinator, + type RuntimeSettingsCommitTarget, +} from "./runtime-settings-commit-coordinator"; import type { RuntimeSettingsTransport } from "./settings-transport"; interface RuntimeSettingsCommitResult { @@ -30,6 +35,10 @@ export interface RuntimeSettingsActionsHost { addSystemMessage: (text: string) => void; } +interface RuntimeSettingsActionsContext extends RuntimeSettingsActionsHost { + runtimeSettingsCommits: RuntimeSettingsCommitCoordinator; +} + export interface ChatRuntimeSettingsActions { applyPendingThreadSettings: () => Promise; requestModel: (model: string) => Promise; @@ -53,36 +62,54 @@ export interface ChatRuntimeSettingsActions { } export function createChatRuntimeSettingsActions(host: RuntimeSettingsActionsHost): ChatRuntimeSettingsActions { - return { - applyPendingThreadSettings: () => applyPendingThreadSettings(host), - requestModel: (model) => requestModel(host, model), - resetModelToConfig: () => resetModelToConfig(host), - requestModelFromUi: (model) => requestModelFromUi(host, model), - requestReasoningEffort: (effort) => requestReasoningEffort(host, effort), - resetReasoningEffortToConfig: () => resetReasoningEffortToConfig(host), - requestReasoningEffortFromUi: (effort) => requestReasoningEffortFromUi(host, effort), - resetReasoningEffortToConfigFromUi: () => resetReasoningEffortToConfigFromUi(host), - requestPermissionProfile: (permissionProfile) => requestPermissionProfile(host, permissionProfile), - resetPermissionProfileToConfig: () => resetPermissionProfileToConfig(host), - enableFastMode: () => setFastMode(host, "enabled"), - disableFastMode: () => setFastMode(host, "disabled"), - toggleFastMode: () => toggleFastMode(host), - toggleCollaborationMode: () => toggleCollaborationMode(host), - setCollaborationMode: (collaborationMode) => setCollaborationMode(host, collaborationMode), - requestDefaultCollaborationModeForNextTurn: () => { - requestDefaultCollaborationModeForNextTurn(host); + const runtimeSettingsCommits = createRuntimeSettingsCommitCoordinator({ + activeThreadId: () => activeThreadId(state(host)), + pendingPatch: () => currentPendingRuntimeSettingsPatch(host), + updateThreadSettings: (threadId, update) => host.runtimeTransport.updateThreadSettings(threadId, update), + commitPatch: (update) => { + dispatch(host, { type: "runtime/pending-thread-settings-committed", update }); }, - enableAutoReview: () => setAutoReview(host, "enabled"), - disableAutoReview: () => setAutoReview(host, "disabled"), - toggleAutoReview: () => toggleAutoReview(host), + reportError: (error) => { + host.addSystemMessage(error instanceof Error ? error.message : String(error)); + }, + }); + const context: RuntimeSettingsActionsContext = { ...host, runtimeSettingsCommits }; + return { + applyPendingThreadSettings: () => applyPendingThreadSettings(context), + requestModel: (model) => requestModel(context, model), + resetModelToConfig: () => resetModelToConfig(context), + requestModelFromUi: (model) => requestModelFromUi(context, model), + requestReasoningEffort: (effort) => requestReasoningEffort(context, effort), + resetReasoningEffortToConfig: () => resetReasoningEffortToConfig(context), + requestReasoningEffortFromUi: (effort) => requestReasoningEffortFromUi(context, effort), + resetReasoningEffortToConfigFromUi: () => resetReasoningEffortToConfigFromUi(context), + requestPermissionProfile: (permissionProfile) => requestPermissionProfile(context, permissionProfile), + resetPermissionProfileToConfig: () => resetPermissionProfileToConfig(context), + enableFastMode: () => setFastMode(context, "enabled"), + disableFastMode: () => setFastMode(context, "disabled"), + toggleFastMode: () => toggleFastMode(context), + toggleCollaborationMode: () => toggleCollaborationMode(context), + setCollaborationMode: (collaborationMode) => setCollaborationMode(context, collaborationMode), + requestDefaultCollaborationModeForNextTurn: () => { + requestDefaultCollaborationModeForNextTurn(context); + }, + enableAutoReview: () => setAutoReview(context, "enabled"), + disableAutoReview: () => setAutoReview(context, "disabled"), + toggleAutoReview: () => toggleAutoReview(context), }; } -async function applyPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise { - return (await commitPendingThreadSettings(host)).ok; +async function applyPendingThreadSettings( + host: RuntimeSettingsActionsContext, + fields?: readonly (keyof RuntimeSettingsPatch)[], +): Promise { + return (await commitPendingThreadSettings(host, fields)).ok; } -async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Promise { +async function commitPendingThreadSettings( + host: RuntimeSettingsActionsContext, + fields?: readonly (keyof RuntimeSettingsPatch)[], +): Promise { const threadId = activeThreadId(state(host)); if (!threadId) return { ok: true, collaborationModeApplied: true }; @@ -91,70 +118,58 @@ async function commitPendingThreadSettings(host: RuntimeSettingsActionsHost): Pr const collaborationModeApplied = !collaborationModeWarning && "collaborationMode" in update; if (Object.keys(update).length === 0) return { ok: true, collaborationModeApplied }; - try { - if (activePanelOperationBlocked(host, "thread-settings")) return { ok: false, collaborationModeApplied: false }; - if (!(await host.runtimeTransport.updateThreadSettings(threadId, update))) { - return { ok: false, collaborationModeApplied: false }; - } - if (activeThreadId(state(host)) !== threadId) return { ok: false, collaborationModeApplied: false }; - if (!runtimeSettingsPatchStillPending(currentPendingRuntimeSettingsPatch(host), update)) { - return { ok: false, collaborationModeApplied: false }; - } - dispatch(host, { type: "runtime/pending-thread-settings-committed", update }); - return { ok: true, collaborationModeApplied }; - } catch (error) { - if (activeThreadId(state(host)) !== threadId) return { ok: false, collaborationModeApplied: false }; - host.addSystemMessage(error instanceof Error ? error.message : String(error)); - return { ok: false, collaborationModeApplied: false }; - } + if (activePanelOperationBlocked(host, "thread-settings")) return { ok: false, collaborationModeApplied: false }; + const target = runtimeSettingsCommitTarget(update, fields); + const ok = await host.runtimeSettingsCommits.commit(threadId, target); + return { ok, collaborationModeApplied: ok && collaborationModeApplied }; } -async function requestModel(host: RuntimeSettingsActionsHost, model: string): Promise { +async function requestModel(host: RuntimeSettingsActionsContext, model: string): Promise { if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/model-requested", model }); - return applyPendingThreadSettings(host); + return applyPendingThreadSettings(host, ["model"]); } -async function resetModelToConfig(host: RuntimeSettingsActionsHost): Promise { +async function resetModelToConfig(host: RuntimeSettingsActionsContext): Promise { if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/model-reset-to-config" }); - return applyPendingThreadSettings(host); + return applyPendingThreadSettings(host, ["model"]); } -async function requestModelFromUi(host: RuntimeSettingsActionsHost, model: string): Promise { +async function requestModelFromUi(host: RuntimeSettingsActionsContext, model: string): Promise { await runRuntimeUiCommand(host, () => requestModel(host, model), modelOverrideMessage(model)); } -async function requestReasoningEffort(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise { +async function requestReasoningEffort(host: RuntimeSettingsActionsContext, effort: ReasoningEffort): Promise { if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/reasoning-effort-requested", effort }); - return applyPendingThreadSettings(host); + return applyPendingThreadSettings(host, ["effort"]); } -async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsHost): Promise { +async function resetReasoningEffortToConfig(host: RuntimeSettingsActionsContext): Promise { if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/reasoning-effort-reset-to-config" }); - return applyPendingThreadSettings(host); + return applyPendingThreadSettings(host, ["effort"]); } -async function requestReasoningEffortFromUi(host: RuntimeSettingsActionsHost, effort: ReasoningEffort): Promise { +async function requestReasoningEffortFromUi(host: RuntimeSettingsActionsContext, effort: ReasoningEffort): Promise { await runRuntimeUiCommand(host, () => requestReasoningEffort(host, effort), reasoningEffortOverrideMessage(effort)); } -async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHost): Promise { +async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsContext): Promise { await runRuntimeUiCommand(host, () => resetReasoningEffortToConfig(host), reasoningEffortOverrideMessage(null)); } -async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise { +async function requestPermissionProfile(host: RuntimeSettingsActionsContext, permissionProfile: string): Promise { if (activePanelOperationBlocked(host, "permission-settings")) return false; dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile }); - return applyPendingThreadSettings(host); + return applyPendingThreadSettings(host, ["permissions"]); } -async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise { +async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsContext): Promise { if (activePanelOperationBlocked(host, "permission-settings")) return false; dispatch(host, { type: "runtime/permission-profile-reset-to-config" }); - return applyPendingThreadSettings(host); + return applyPendingThreadSettings(host, ["permissions"]); } function activePanelOperationBlocked(host: RuntimeSettingsActionsHost, operation: ActivePanelOperation): boolean { @@ -164,34 +179,34 @@ function activePanelOperationBlocked(host: RuntimeSettingsActionsHost, operation return true; } -async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise { +async function toggleFastMode(host: RuntimeSettingsActionsContext): Promise { const { snapshot, config } = runtimeProjection(host); await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled"); } -async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState): Promise { +async function setFastMode(host: RuntimeSettingsActionsContext, mode: FastModeState): Promise { if (activePanelOperationBlocked(host, "thread-settings")) return; const fastMode: RequestedFastMode = mode; await runRuntimeUiCommand( host, async () => { dispatch(host, { type: "runtime/fast-mode-requested", fastMode }); - return applyPendingThreadSettings(host); + return applyPendingThreadSettings(host, ["serviceTier"]); }, mode === "enabled" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns.", ); } -async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promise { +async function toggleCollaborationMode(host: RuntimeSettingsActionsContext): Promise { const { snapshot, config } = runtimeProjection(host); const next = nextCollaborationMode(resolveRuntimeControls(snapshot, config).collaborationMode.effective); await setCollaborationMode(host, next); } -async function setCollaborationMode(host: RuntimeSettingsActionsHost, collaborationMode: CollaborationModeSelection): Promise { +async function setCollaborationMode(host: RuntimeSettingsActionsContext, collaborationMode: CollaborationModeSelection): Promise { if (activePanelOperationBlocked(host, "thread-settings")) return false; dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode }); - const result = await commitPendingThreadSettings(host); + const result = await commitPendingThreadSettings(host, ["collaborationMode"]); if (result.ok) closeRuntimePanel(host); if (result.ok && result.collaborationModeApplied) { host.addSystemMessage(collaborationMode === "plan" ? "Plan mode on for subsequent turns." : "Plan mode off for subsequent turns."); @@ -204,19 +219,19 @@ function requestDefaultCollaborationModeForNextTurn(host: RuntimeSettingsActions dispatch(host, { type: "runtime/requested-collaboration-mode-set", collaborationMode: "default" }); } -async function toggleAutoReview(host: RuntimeSettingsActionsHost): Promise { +async function toggleAutoReview(host: RuntimeSettingsActionsContext): Promise { const { snapshot, config } = runtimeProjection(host); const nextState = resolveRuntimeControls(snapshot, config).autoReview.active ? "disabled" : "enabled"; await setAutoReview(host, nextState); } -async function setAutoReview(host: RuntimeSettingsActionsHost, mode: AutoReviewState): Promise { +async function setAutoReview(host: RuntimeSettingsActionsContext, mode: AutoReviewState): Promise { if (activePanelOperationBlocked(host, "thread-settings")) return; await runRuntimeUiCommand( host, async () => { dispatch(host, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: mode === "enabled" ? "auto_review" : "user" }); - return applyPendingThreadSettings(host); + return applyPendingThreadSettings(host, ["approvalsReviewer"]); }, mode === "enabled" ? "Auto-review on for subsequent turns." : "Auto-review off for subsequent turns.", ); @@ -256,33 +271,16 @@ function currentPendingRuntimeSettingsPatch(host: RuntimeSettingsActionsHost): R return buildPendingRuntimeSettingsPatch(snapshot, config).update; } -function runtimeSettingsPatchStillPending(current: RuntimeSettingsPatch, applied: RuntimeSettingsPatch): boolean { - return (Object.keys(applied) as (keyof RuntimeSettingsPatch)[]).every((key) => { - return key in current && threadSettingsValueEqual(current[key], applied[key]); - }); -} - -function threadSettingsValueEqual(left: unknown, right: unknown): boolean { - if (Object.is(left, right)) return true; - if (Array.isArray(left) || Array.isArray(right)) { - return ( - Array.isArray(left) && - Array.isArray(right) && - left.length === right.length && - left.every((value, index) => threadSettingsValueEqual(value, right[index])) - ); +function runtimeSettingsCommitTarget( + update: RuntimeSettingsPatch, + fields: readonly (keyof RuntimeSettingsPatch)[] | undefined, +): RuntimeSettingsCommitTarget { + if (!fields) return { kind: "settle" }; + const targetedUpdate: RuntimeSettingsPatch = {}; + for (const key of fields) { + if (key in update) Object.assign(targetedUpdate, { [key]: update[key] }); } - if (!isPlainRecord(left) || !isPlainRecord(right)) return false; - const leftKeys = Object.keys(left); - const rightKeys = Object.keys(right); - return ( - leftKeys.length === rightKeys.length && - leftKeys.every((key) => Object.hasOwn(right, key) && threadSettingsValueEqual(left[key], right[key])) - ); -} - -function isPlainRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; + return Object.keys(targetedUpdate).length === 0 ? { kind: "settle" } : { kind: "fields", update: targetedUpdate }; } function runtimeProjection(host: RuntimeSettingsActionsHost): { diff --git a/tests/features/chat/application/runtime/settings-actions.test.ts b/tests/features/chat/application/runtime/settings-actions.test.ts index 2628d48f..7aa3008f 100644 --- a/tests/features/chat/application/runtime/settings-actions.test.ts +++ b/tests/features/chat/application/runtime/settings-actions.test.ts @@ -458,17 +458,51 @@ describe("createChatRuntimeSettingsActions", () => { expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { model: "gpt-old" }); const secondRequest = actions.requestModel("gpt-new"); + expect(transport.updateThreadSettings).toHaveBeenCalledTimes(1); + + firstUpdate.resolve(); + await expect(firstRequest).resolves.toBe(false); expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { model: "gpt-new" }); secondUpdate.resolve(); await expect(secondRequest).resolves.toBe(true); expect(store.getState().runtime.active.model).toBe("gpt-new"); expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" }); + expect(messages).toEqual([]); + }); - firstUpdate.resolve(); - await expect(firstRequest).resolves.toBe(false); - expect(store.getState().runtime.active.model).toBe("gpt-new"); + it("coalesces a different-field intent behind the active settings update", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { activeThread: { id: "thread" } }); + const store = createChatStateStore(state); + const modelUpdate = deferred(true); + const effortUpdate = deferred(true); + const transport = settingsTransportFixture({ + updateThreadSettings: vi + .fn() + .mockImplementationOnce(() => modelUpdate.promise) + .mockImplementationOnce(() => effortUpdate.promise), + }); + const messages: string[] = []; + const actions = runtimeActionsFixture(store, transport, messages); + + const modelRequest = actions.requestModel("gpt-5.5"); + expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { model: "gpt-5.5" }); + + const effortRequest = actions.requestReasoningEffort("high"); + expect(transport.updateThreadSettings).toHaveBeenCalledTimes(1); + + modelUpdate.resolve(); + await expect(modelRequest).resolves.toBe(true); + expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { effort: "high" }); + expect(store.getState().runtime.active.model).toBe("gpt-5.5"); expect(store.getState().runtime.pending.model).toEqual({ kind: "unchanged" }); + expect(store.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); + + effortUpdate.resolve(); + await expect(effortRequest).resolves.toBe(true); + expect(store.getState().runtime.active.reasoningEffort).toBe("high"); + expect(store.getState().runtime.pending.reasoningEffort).toEqual({ kind: "unchanged" }); expect(messages).toEqual([]); });