diff --git a/docs/design/model-reasoning-capabilities.md b/docs/design/model-reasoning-capabilities.md index 1fbf552390d..cd70e8c80d3 100644 --- a/docs/design/model-reasoning-capabilities.md +++ b/docs/design/model-reasoning-capabilities.md @@ -37,15 +37,20 @@ The model manifest distinguishes tiered reasoning from toggle-only reasoning and continues to match exact model ids. Toggle-only models produce the same ACP configuration option as tiered models, with two values: `none` and `default`. Selecting `none` disables thinking for the live session. Selecting `default` -clears the session override so the existing model or provider default applies. +clears the live override for ordinary ACP clients so the existing model or +provider default applies. WebShell's persistent path also clears the selected +settings scope and adopts the resulting merged preference, which may be an +inherited setting. The daemon marks toggle-only options in ACP metadata. WebShell maps them to an empty effort list, renders only the Thinking switch, and shows `Thinking` or `Thinking Off` on the model chip. Existing tiered controls retain their effort rows and labels. -Opening the controls does not mutate generation settings. No provider, -authentication, persistence, or runtime-snapshot behavior changes. +Opening the controls does not mutate generation settings. Capability +registration itself does not change provider, authentication, or +runtime-snapshot behavior. WebShell selections follow the separate +[reasoning effort persistence contract](./webshell-reasoning-effort-persistence.md). ## Deferred models @@ -58,6 +63,9 @@ whose selected value is not sent correctly. Qwen aliases, dated variants, coder models, and models with a preset default that differs from the model default are also deferred. They require separate capability or resolved-configuration semantics rather than broadened matching. +Deferred Qwen-family models expose no ACP reasoning option until their exact +model id is registered. The generic ACP reasoning option remains available for +non-Qwen models so existing provider-specific behavior is preserved. ## Compatibility diff --git a/docs/design/webshell-qwen38-reasoning-config.md b/docs/design/webshell-qwen38-reasoning-config.md index 3988600af26..488038b89d6 100644 --- a/docs/design/webshell-qwen38-reasoning-config.md +++ b/docs/design/webshell-qwen38-reasoning-config.md @@ -15,10 +15,12 @@ Thinking and the native effort values `low`, `medium`, and `xhigh`, with does not apply to preview, dated, aliased, or runtime models. The agent projects that entry through ACP's existing `reasoning_effort` -configuration option. For this model only, the option contains `none` plus the -three manifest values. WebShell renders `none` as Thinking off and renders the -remaining values as effort choices. No second effort configuration id is -introduced. +configuration option. For this model only, the option contains `none`, +`default`, and the three manifest values. `default` remains an ACP instruction +for clearing an override, not a model effort. WebShell therefore renders +`none` as Thinking off and the manifest values as effort choices; when ACP +reports `default`, it displays the manifest's default effort. No second effort +configuration id is introduced. Both workspace-provider producers expose that same manifest-built option as an optional, per-model `configOptions` preview. The field is an additive v1 @@ -49,9 +51,17 @@ WebShell releases the unused session, keeps the composer and intent available for retry, and does not send the prompt. The intent is cleared after successful preparation or after an unrelated -session is attached. It is not a workspace default and is never persisted or -broadcast. If the user changes models before submission, the model-bound -intent is not applied to the other model. +session is attached. Before it is applied, this local intent is not a workspace +default and is neither persisted nor broadcast. Once WebShell applies it +through the daemon-owned config-option mutation, the live session changes. +`none`, native efforts, and custom efforts are persisted as the shared default +for later sessions. WebShell does not offer ACP's `default` instruction as an +effort choice, and settings can still clear the selected scope. Ordinary ACP +clients may send `default` to restore the live session's model or provider +baseline without persistence; a private persistent request also clears its +settings scope and applies the resulting merged preference. If the user changes +models before submission, the model-bound intent is not applied to the other +model. WebShell retains PR #8675's interaction design: the current reasoning state is shown as a suffix on the model chip, reasoning options occupy the first model @@ -79,6 +89,11 @@ and unrelated request parameters, and applies the selected value. This keeps the control truthful instead of acknowledging a choice that a higher-priority field would silently shadow. +When an ACP client selects `default`, those static thinking fields are restored +from the current model baseline in the live session. This makes clearing an +explicit preference consistent with a new session or daemon restart while +preserving unrelated request parameters and the stored model configuration. + When the active model requires thinking, ACP omits `none` from the option and marks that constraint in metadata. WebShell keeps the Thinking switch checked and disabled while leaving every supported effort selectable. An unmarked @@ -90,8 +105,12 @@ mandatory before the first prompt. The daemon exposes one owner-routed config-option mutation. Its public route is restricted to `reasoning_effort`; the response carries fresh `configOptions`, -which becomes the caller's authoritative UI state. No observer or broadcast is -added. +which becomes the caller's authoritative UI state. WebShell marks this update +for persistence, while ordinary ACP clients remain session-only. A successful +settings write emits the existing scope-aware `settings_changed` event; a +failed write leaves the live session updated but does not broadcast. The full +settings and lifecycle contract is defined in +[WebShell reasoning effort persistence](./webshell-reasoning-effort-persistence.md). ## Scope @@ -102,7 +121,9 @@ Included: - application after lazy attach and model selection but before the first prompt; - authoritative replacement by same-session context; -- the current WebShell conversation; +- the current WebShell conversation and the shared reasoning default for later + sessions; +- scope-aware settings refresh after successful WebShell persistence; - effort changes after completed messages and while a prompt is running; - Thinking on/off and `low`, `medium`, `xhigh` effort; - browser coverage for welcome, live override, model switching, old daemons, @@ -110,13 +131,14 @@ Included: Excluded: -- persistence across sessions or restarts; -- persisted/default-model semantics; -- workspace-level, reusable, or cross-client reasoning defaults; +- a per-model persisted/default mapping beyond the shared + `model.reasoningEffort` setting; +- persistence for ordinary ACP config-option mutations; - preview, aliases, and future reasoning-control shapes; - route and runtime models; -- TUI, channel, provider, auth-refresh, and runtime-snapshot behavior; -- capability flags and cross-client model/config broadcasts. +- TUI and channel behavior; +- capability flags and general model/config broadcasts outside the existing + scope-aware settings refresh. ## Compatibility diff --git a/docs/design/webshell-reasoning-effort-persistence.md b/docs/design/webshell-reasoning-effort-persistence.md new file mode 100644 index 00000000000..af03fee9b52 --- /dev/null +++ b/docs/design/webshell-reasoning-effort-persistence.md @@ -0,0 +1,39 @@ +# WebShell reasoning effort persistence + +## Problem + +WebShell currently applies a reasoning effort selection only to the active ACP session. A new session therefore falls back to the previously configured model default. The settings schema also treats effort as one global five-value scale, even though providers can expose model-specific values such as `ultra` or `minimal`. + +## Semantics + +`model.reasoningEffort` is an open string setting: + +- An absent value leaves reasoning at the model or provider default. +- `none` disables reasoning by setting the runtime configuration to `reasoning: false`. +- Every other non-empty value is preserved verbatim as `reasoning.effort`. + +There is no global normalization, ordering, allowlist, or fallback for persisted values. The existing five built-in effort tiers remain available to `/effort` and provider adapters that explicitly implement that scale. Provider adapters may translate built-in tiers for their own wire protocol, but must pass an unknown value through or reject it explicitly rather than silently changing it. + +ACP uses `default` only as an interaction instruction to remove `model.reasoningEffort`; that ACP path never writes the sentinel. The open setting does not reserve `default` globally, so a value authored directly in settings remains an opaque provider value. After clearing the selected scope, the live session adopts the resulting merged value, including an inherited opaque string from a lower-precedence scope. + +Persisting a model switch to a toggle-only Qwen model clears `model.reasoningEffort` from every active writable settings scope and clears the live override after the switch succeeds. Switching to a non-Qwen model performs the same cleanup only for an opaque provider-specific effort; built-in tiers remain portable. Session-only and managed-session switches clear only an incompatible live override without mutating the persisted default. Read-only managed settings are also left unchanged while their incompatible live override is removed. Switches to tiered qwen3.8-max models preserve the effort value. + +## Persistence boundary + +The WebShell daemon route marks its ACP `reasoning_effort` update as persistent. The ACP agent applies the selection to the live session first, then writes through the `Session` instance's own loaded settings using the same scope selection as model persistence. Direct ACP calls without the private marker remain session-only. + +After a successful persisted update, the bridge broadcasts `settings_changed` for `model.reasoningEffort` with the actual user or workspace scope that was written. User-scope changes fan out across every live workspace bridge in the daemon; workspace-scope changes stay on their owning bridge. This refreshes retained workspace provider state and the no-session welcome preview without creating an empty session. + +If the settings write fails, the live session remains updated and the request reports that the current session changed but the default was not saved. No settings-change event is broadcast. + +## Runtime lifecycle + +The runtime keeps an explicit reasoning override across authentication refreshes and both hot and full model rebuilds. The override distinguishes disabled reasoning, an opaque effort string, and the provider default. This prevents `none` from being lost merely because it has no `effort` field. + +When an explicit preference is active for a tiered qwen3.8 model on DashScope, the runtime removes higher-priority `extra_body` and `samplingParams` thinking knobs from its live configuration with copy-on-write semantics. This keeps the persisted preference effective after new-session construction and authentication refresh without rewriting the model's stored generation configuration. With no explicit preference, those static model defaults remain unchanged. + +Model reasoning capabilities advertise options as independent `value` and `name` pairs. WebShell stores and submits `value` while rendering `name`, so model-specific values do not depend on global translation keys. + +## Validation + +Unit and integration coverage verifies opaque string round trips, `none`, clearing to provider defaults, lifecycle rebuilds, capability labels, the private persistence marker, write failures, welcome previews, provider wire behavior, and persisted model switches into toggle-only Qwen models. Real-daemon E2E compares a clean baseline and this change with isolated configuration directories for both Medium and Thinking Off across a daemon restart and a newly created session, then verifies that switching to a toggle-only Qwen model removes the persisted effort before the next request. diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 701388350a9..73ce07188b1 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -174,7 +174,7 @@ These settings are read from operator scopes only (User, System, and SystemDefau | Setting | Type | Description | Default | | -------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | `model.name` | string | The Qwen model to use for conversations. | `undefined` | -| `model.reasoningEffort` | enum | How hard reasoning-capable models think, applied across all providers. Set with the [`/effort`](../features/commands) command (`low`, `medium`, `high`, `xhigh`, `max`). Each provider maps and clamps this to what the active model supports (e.g. Gemini caps at `high`; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default. | `undefined` | +| `model.reasoningEffort` | string | The model-specific reasoning effort to use for new sessions. The exact lowercase value `none` disables reasoning; any other non-empty value is preserved verbatim for the selected model and provider. Leave unset to use the model/provider default. The [`/effort`](../features/commands) command writes one of its built-in tiers to this setting; use the setting directly for model-specific values. | `undefined` | | `model.baseUrl` | string | Persisted automatically by the model picker to disambiguate when multiple `modelProviders` entries share the same model id. Not intended to be set by hand — use the `/model` picker or a `modelProviders` entry instead; a stale hand-edited value can silently route requests to a different same-id provider. | `undefined` | | `model.sessionTokenLimit` | number | Maximum recorded prompt token count allowed before sending the next message. `-1` means unlimited; `0` is also treated as unlimited (unlike `model.maxToolCalls`, where `0` disallows all calls). When the recorded prompt count exceeds the limit, the next send is dropped (the session is not aborted). | `-1` | | `model.maxSessionTurns` | integer | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` | diff --git a/integration-tests/cli/acp-integration.test.ts b/integration-tests/cli/acp-integration.test.ts index e3cc55c78d1..0349546794a 100644 --- a/integration-tests/cli/acp-integration.test.ts +++ b/integration-tests/cli/acp-integration.test.ts @@ -552,7 +552,7 @@ function setupAcpTest( }); expect( initialReasoningOption?.options.map((option) => option.value), - ).toEqual(['default', 'low', 'medium', 'high', 'xhigh', 'max']); + ).toContain('none'); // Test: Set mode using set_config_option const setModeResult = (await sendRequest('session/set_config_option', { @@ -627,7 +627,7 @@ function setupAcpTest( { sessionId: newSession.sessionId, configId: 'reasoning_effort', - value: 'xhigh', + value: 'ultra', }, )) as { configOptions: Array<{ id: string; currentValue: string }>; @@ -636,7 +636,7 @@ function setupAcpTest( setReasoningResult.configOptions.find( (opt) => opt.id === 'reasoning_effort', )?.currentValue, - ).toBe('xhigh'); + ).toBe('ultra'); const resetReasoningResult = (await sendRequest( 'session/set_config_option', @@ -658,13 +658,12 @@ function setupAcpTest( sendRequest('session/set_config_option', { sessionId: newSession.sessionId, configId: 'reasoning_effort', - value: 'ultra', + value: ' ', }), ).rejects.toMatchObject({ response: { code: -32602, - message: - 'Invalid params: Unknown reasoning effort: ultra. Choose one of: default, low, medium, high, xhigh, max', + message: 'Invalid params: Reasoning effort cannot be blank', }, }); } catch (e) { diff --git a/packages/acp-bridge/package.json b/packages/acp-bridge/package.json index 7458be9c860..5bd09c07173 100644 --- a/packages/acp-bridge/package.json +++ b/packages/acp-bridge/package.json @@ -75,6 +75,10 @@ "types": "./dist/prompt-ledger.d.ts", "import": "./dist/prompt-ledger.js" }, + "./reasoningPersistence": { + "types": "./dist/reasoning-persistence.d.ts", + "import": "./dist/reasoning-persistence.js" + }, "./sessionRestoreTimeout": { "types": "./dist/session-restore-timeout.d.ts", "import": "./dist/session-restore-timeout.js" diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index c969bb21cb2..562a0361fde 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -50,6 +50,10 @@ import { DAEMON_OWNED_STANDALONE_CREATION_KEY, SESSION_SOURCE_META_KEY, } from './session-source.js'; +import { + PERSIST_REASONING_EFFORT_META_KEY, + REASONING_EFFORT_PERSISTENCE_META_KEY, +} from './reasoning-persistence.js'; import { classifyTurnErrorKind, extractErrorMessage, @@ -22938,6 +22942,419 @@ describe('createAcpSessionBridge', () => { }); }); + describe('setSessionConfigOption', () => { + it('keeps standalone reasoning changes session-scoped', async () => { + const sessionId = '11111111-1111-4111-8111-111111111111'; + const publishGlobalWorkspaceEvent = vi.fn(); + const handle = makeChannel({ + newSessionImpl: (params) => ({ + sessionId: String(params._meta?.[REQUESTED_SESSION_ID_META_KEY]), + }), + extMethodImpl: async (method) => + method === SERVE_CONTROL_EXT_METHODS.sessionSource + ? { persisted: true } + : {}, + setSessionConfigOptionImpl: () => ({ configOptions: [] }), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + publishGlobalWorkspaceEvent, + }); + await bridge.spawnStandaloneSession({ workspaceCwd: WS_A, sessionId }); + + await bridge.setSessionConfigOption(sessionId, { + sessionId, + configId: 'reasoning_effort', + value: 'ultra', + _meta: { + [PERSIST_REASONING_EFFORT_META_KEY]: true, + 'qwenCode/testMarker': 'keep', + }, + }); + + expect(handle.agent.setSessionConfigOptionCalls).toEqual([ + { + sessionId, + configId: 'reasoning_effort', + value: 'ultra', + _meta: { 'qwenCode/testMarker': 'keep' }, + }, + ]); + expect(publishGlobalWorkspaceEvent).not.toHaveBeenCalled(); + expect( + bridge + .getSessionReplaySnapshot(sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toEqual([]); + await bridge.shutdown(); + }); + + it('broadcasts a persisted reasoning value after the ACP update succeeds', async () => { + const handle = makeChannel({ + setSessionConfigOptionImpl: () => ({ + configOptions: [], + _meta: { + [REASONING_EFFORT_PERSISTENCE_META_KEY]: { scope: 'user' }, + }, + }), + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + await bridge.setSessionConfigOption(session.sessionId, { + sessionId: 'spoofed', + configId: 'reasoning_effort', + value: 'ultra', + _meta: { [PERSIST_REASONING_EFFORT_META_KEY]: true }, + }); + + expect(handle.agent.setSessionConfigOptionCalls).toEqual([ + { + sessionId: session.sessionId, + configId: 'reasoning_effort', + value: 'ultra', + _meta: { [PERSIST_REASONING_EFFORT_META_KEY]: true }, + }, + ]); + const next = await iter[Symbol.asyncIterator]().next(); + expect(next.value).toMatchObject({ + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: 'ultra', + scope: 'user', + }, + }); + + abort.abort(); + await bridge.shutdown(); + }); + + it('uses daemon-wide fan-out for user-scoped persistence', async () => { + const publishGlobalWorkspaceEvent = vi.fn(); + const handle = makeChannel({ + setSessionConfigOptionImpl: () => ({ + configOptions: [], + _meta: { + [REASONING_EFFORT_PERSISTENCE_META_KEY]: { scope: 'user' }, + }, + }), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + publishGlobalWorkspaceEvent, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.setSessionConfigOption(session.sessionId, { + sessionId: session.sessionId, + configId: 'reasoning_effort', + value: 'medium', + _meta: { [PERSIST_REASONING_EFFORT_META_KEY]: true }, + }); + + expect(publishGlobalWorkspaceEvent).toHaveBeenCalledOnce(); + expect(publishGlobalWorkspaceEvent).toHaveBeenCalledWith({ + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: 'medium', + scope: 'user', + }, + }); + expect( + bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toEqual([]); + await bridge.shutdown(); + }); + + it('promotes child-side user reasoning persistence to daemon-wide fan-out', async () => { + const publishGlobalWorkspaceEvent = vi.fn(); + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + publishGlobalWorkspaceEvent, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await handle.agentConnection.extNotification( + 'qwen/notify/session/settings-changed', + { + v: 1, + sessionId: session.sessionId, + key: 'model.reasoningEffort', + value: 'high', + scope: 'user', + }, + ); + + expect(publishGlobalWorkspaceEvent).toHaveBeenCalledWith({ + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: 'high', + scope: 'user', + }, + }); + expect( + bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toEqual([]); + await bridge.shutdown(); + }); + + it('keeps child-side workspace reasoning persistence on its bridge', async () => { + const publishGlobalWorkspaceEvent = vi.fn(); + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + publishGlobalWorkspaceEvent, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await handle.agentConnection.extNotification( + 'qwen/notify/session/settings-changed', + { + v: 1, + sessionId: session.sessionId, + key: 'model.reasoningEffort', + value: 'medium', + scope: 'workspace', + }, + ); + + expect(publishGlobalWorkspaceEvent).not.toHaveBeenCalled(); + expect( + bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toContainEqual( + expect.objectContaining({ + data: { + key: 'model.reasoningEffort', + value: 'medium', + scope: 'workspace', + }, + }), + ); + await bridge.shutdown(); + }); + + it('drops malformed child-side reasoning setting notifications', async () => { + const publishGlobalWorkspaceEvent = vi.fn(); + const handle = makeChannel(); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + publishGlobalWorkspaceEvent, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await handle.agentConnection.extNotification( + 'qwen/notify/session/settings-changed', + { + v: 1, + sessionId: session.sessionId, + key: 'model.reasoningEffort', + value: 3, + scope: 'user', + }, + ); + await handle.agentConnection.extNotification( + 'qwen/notify/session/settings-changed', + { + v: 1, + sessionId: 'unknown-session', + key: 'model.reasoningEffort', + value: 'high', + scope: 'user', + }, + ); + + expect(publishGlobalWorkspaceEvent).not.toHaveBeenCalled(); + expect( + bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toEqual([]); + await bridge.shutdown(); + }); + + it('broadcasts a cleared persisted reasoning value as null', async () => { + const publishGlobalWorkspaceEvent = vi.fn(); + const handle = makeChannel({ + setSessionConfigOptionImpl: () => ({ + configOptions: [], + _meta: { + [REASONING_EFFORT_PERSISTENCE_META_KEY]: { scope: 'workspace' }, + }, + }), + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + publishGlobalWorkspaceEvent, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.setSessionConfigOption(session.sessionId, { + sessionId: session.sessionId, + configId: 'reasoning_effort', + value: 'default', + _meta: { [PERSIST_REASONING_EFFORT_META_KEY]: true }, + }); + + expect( + bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toContainEqual( + expect.objectContaining({ + data: { + key: 'model.reasoningEffort', + value: null, + scope: 'workspace', + }, + }), + ); + expect(publishGlobalWorkspaceEvent).not.toHaveBeenCalled(); + await bridge.shutdown(); + }); + + it.each([ + { + name: 'missing persistence metadata', + response: { configOptions: [] }, + }, + { + name: 'an invalid persistence scope', + response: { + configOptions: [], + _meta: { + [REASONING_EFFORT_PERSISTENCE_META_KEY]: { scope: 'system' }, + }, + }, + }, + ])('does not broadcast after $name', async ({ response }) => { + const publishGlobalWorkspaceEvent = vi.fn(); + const handle = makeChannel({ + setSessionConfigOptionImpl: () => response, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + publishGlobalWorkspaceEvent, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.setSessionConfigOption(session.sessionId, { + sessionId: session.sessionId, + configId: 'reasoning_effort', + value: 'medium', + _meta: { [PERSIST_REASONING_EFFORT_META_KEY]: true }, + }); + + expect(publishGlobalWorkspaceEvent).not.toHaveBeenCalled(); + expect( + bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toEqual([]); + await bridge.shutdown(); + }); + + it('does not broadcast session-only or failed reasoning updates', async () => { + let shouldFail = false; + const handle = makeChannel({ + setSessionConfigOptionImpl: () => { + if (shouldFail) throw new Error('settings write failed'); + return { configOptions: [] }; + }, + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.setSessionConfigOption(session.sessionId, { + sessionId: session.sessionId, + configId: 'reasoning_effort', + value: 'medium', + }); + shouldFail = true; + await expect( + bridge.setSessionConfigOption(session.sessionId, { + sessionId: session.sessionId, + configId: 'reasoning_effort', + value: 'ultra', + _meta: { [PERSIST_REASONING_EFFORT_META_KEY]: true }, + }), + ).rejects.toThrow(); + + expect( + bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toEqual([]); + await bridge.shutdown(); + }); + + it('broadcasts once when persistence succeeds after the caller times out', async () => { + const persistence = deferred<{ + configOptions: []; + _meta: Record; + }>(); + const handle = makeChannel({ + setSessionConfigOptionImpl: () => persistence.promise, + }); + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + initializeTimeoutMs: 20, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await expect( + bridge.setSessionConfigOption(session.sessionId, { + sessionId: session.sessionId, + configId: 'reasoning_effort', + value: 'ultra', + _meta: { [PERSIST_REASONING_EFFORT_META_KEY]: true }, + }), + ).rejects.toBeInstanceOf(BridgeTimeoutError); + expect( + bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toEqual([]); + + persistence.resolve({ + configOptions: [], + _meta: { + [REASONING_EFFORT_PERSISTENCE_META_KEY]: { scope: 'user' }, + }, + }); + await persistence.promise; + await vi.waitFor(() => + expect( + bridge + .getSessionReplaySnapshot(session.sessionId) + ?.liveJournal.filter((event) => event.type === 'settings_changed'), + ).toEqual([ + expect.objectContaining({ + data: { + key: 'model.reasoningEffort', + value: 'ultra', + scope: 'user', + }, + }), + ]), + ); + await bridge.shutdown(); + }); + }); + describe('setSessionModel', () => { /** Set up a channel where the agent records setSessionModel calls. */ async function setup(response: Record = {}) { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 25ae83e8844..9fb7450a0a0 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -131,6 +131,10 @@ import { SESSION_SOURCE_META_KEY, STANDALONE_SESSION_SOURCE_TYPE, } from './session-source.js'; +import { + PERSIST_REASONING_EFFORT_META_KEY, + REASONING_EFFORT_PERSISTENCE_META_KEY, +} from './reasoning-persistence.js'; import { ACTIVE_WORK_CLOSE_IF_UNHELD_PARAM, ACTIVE_WORK_CLOSE_TIMEOUT_MS, @@ -4119,6 +4123,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // nothing else would settle what its last drain missed. settleMidTurnQueueAfterGoalTurn, opts.onCreateCurrentSessionScheduledTask, + publishSettingsChangedEvent, ); const rawConnection = new ClientSideConnection( () => @@ -5842,6 +5847,26 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { } }; + const publishSettingsChangedEvent = ( + event: Omit, + ): void => { + const scope = + event.data && typeof event.data === 'object' + ? (event.data as Record)['scope'] + : undefined; + if (scope === 'user' && opts.publishGlobalWorkspaceEvent !== undefined) { + try { + opts.publishGlobalWorkspaceEvent(event); + } catch (err) { + writeStderrLine( + `qwen serve: global settings_changed broadcast failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } else { + broadcastWorkspaceEvent(event); + } + }; + const createSessionEventBus = (sessionId: string): EventBus => new EventBus( eventRingSize, @@ -11332,17 +11357,50 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const info = channelInfoForEntry(entry); if (!info || info.isDying) throw new SessionNotFoundError(sessionId); const normalized: SetSessionConfigOptionRequest = { ...req, sessionId }; + if ( + isReservedStandaloneSessionSourceType(entry.sourceType) && + normalized.configId === 'reasoning_effort' && + normalized._meta?.[PERSIST_REASONING_EFFORT_META_KEY] === true + ) { + const forwardedMeta = { ...normalized._meta }; + delete forwardedMeta[PERSIST_REASONING_EFFORT_META_KEY]; + normalized._meta = + Object.keys(forwardedMeta).length > 0 ? forwardedMeta : undefined; + } const transportClosed = getTransportClosedReject(entry); - const work = entry.modelChangeQueue.then(() => - Promise.race([ - withTimeout( - entry.connection.setSessionConfigOption(normalized), - initTimeoutMs, - 'setSessionConfigOption', - ), + const work = entry.modelChangeQueue.then(() => { + const operation = entry.connection + .setSessionConfigOption(normalized) + .then((response) => { + if ( + normalized.configId === 'reasoning_effort' && + normalized._meta?.[PERSIST_REASONING_EFFORT_META_KEY] === true + ) { + const persistence = + response._meta?.[REASONING_EFFORT_PERSISTENCE_META_KEY]; + const scope = + typeof persistence === 'object' && persistence !== null + ? (persistence as Record)['scope'] + : undefined; + if (scope === 'user' || scope === 'workspace') { + const event = { + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: req.value === 'default' ? null : req.value, + scope, + }, + } as const; + publishSettingsChangedEvent(event); + } + } + return response; + }); + return Promise.race([ + withTimeout(operation, initTimeoutMs, 'setSessionConfigOption'), transportClosed, - ]), - ); + ]); + }); entry.modelChangeQueue = work.then( () => undefined, () => undefined, diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index fa45ce87a83..177b4c03486 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -75,6 +75,7 @@ import type { } from './bridgeOptions.js'; import { CancelSentinelCollisionError } from './bridgeErrors.js'; import { CANCEL_VOTE_SENTINEL } from './permissionMediator.js'; +import type { BridgeEvent } from './eventBus.js'; import { SessionArtifactStore } from './sessionArtifacts.js'; import { SESSION_ATTACHMENT_MAX_ITEM_BYTES, @@ -106,6 +107,11 @@ function makeClient( import('./bridgeOptions.js').BridgeOptions['onCreateCurrentSessionScheduledTask'] >; }, + settingsChanged?: { + resolveEntry: (sessionId?: string) => unknown; + ownsSession?: (sessionId: string) => boolean; + handler: (event: Omit) => void; + }, ): BridgeClient { const noPermissionFlow = () => { throw new Error('test: permission flow should not run in fs-path tests'); @@ -119,6 +125,7 @@ function makeClient( return new BridgeClient( (managedGuard?.resolveEntry ?? currentSessionTask?.resolveEntry ?? + settingsChanged?.resolveEntry ?? noPermissionFlow) as never, // resolveEntry noPermissionFlow as never, // resolvePendingRestoreEvents throwerMediator, // mediator (F3 Commit 3) @@ -130,6 +137,7 @@ function makeClient( undefined, managedGuard?.ownsSession ?? currentSessionTask?.ownsSession ?? + settingsChanged?.ownsSession ?? (() => true), undefined, undefined, @@ -145,6 +153,7 @@ function makeClient( undefined, undefined, currentSessionTask?.handler, + settingsChanged?.handler, ); } @@ -762,6 +771,45 @@ describe('BridgeClient — recording degradation ownership', () => { }); }); +describe('BridgeClient — settings notification ownership', () => { + it('accepts only v1 notifications from the channel that owns the session', async () => { + const sessionId = 'session-owned-by-current-channel'; + const handler = vi.fn(); + let ownsSession = false; + const client = makeClient(undefined, undefined, undefined, { + resolveEntry: (id) => + id === sessionId + ? { sessionId, events: { publish: vi.fn() } } + : undefined, + ownsSession: () => ownsSession, + handler, + }); + const notification = { + sessionId, + key: 'model.reasoningEffort', + value: 'high', + scope: 'user', + }; + + await client.extNotification('qwen/notify/session/settings-changed', { + v: 1, + ...notification, + }); + ownsSession = true; + await client.extNotification('qwen/notify/session/settings-changed', { + v: 2, + ...notification, + }); + expect(handler).not.toHaveBeenCalled(); + + await client.extNotification('qwen/notify/session/settings-changed', { + v: 1, + ...notification, + }); + expect(handler).toHaveBeenCalledOnce(); + }); +}); + describe('BridgeClient — BridgeFileSystem injection seam (F1 step 5)', () => { describe('writeTextFile', () => { it('delegates to the injected fileSystem.writeText, bypassing the inline fs proxy', async () => { diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 9bdb5ff2bdc..d4047931e46 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -900,6 +900,9 @@ export class BridgeClient implements Client { */ private readonly onGoalTurnEnded?: (sessionId: string) => void, private readonly onCreateCurrentSessionScheduledTask?: CurrentSessionScheduledTaskCreateHandler, + private readonly onSettingsChanged?: ( + event: Omit, + ) => void, ) {} async requestPermission( @@ -2152,6 +2155,7 @@ export class BridgeClient implements Client { * Handle child->bridge ACP `extNotification` calls. Recognized methods are * `qwen/notify/session/model-update`, * `qwen/notify/session/mode-update`, + * `qwen/notify/session/settings-changed`, * `qwen/notify/session/title-update` (auto/in-process session titles), * `qwen/notify/session/recording-degraded`, * `qwen/notify/session/prompt-suggestion` (followup assist), @@ -2366,6 +2370,10 @@ export class BridgeClient implements Client { this.handleInSessionModelUpdate(params); return; } + if (method === 'qwen/notify/session/settings-changed') { + this.handleInSessionSettingsChanged(params); + return; + } if (method === 'qwen/notify/session/mode-update') { this.handleInSessionModeUpdate(params); return; @@ -2739,6 +2747,39 @@ export class BridgeClient implements Client { ); } + private handleInSessionSettingsChanged( + params: Record, + ): void { + const sessionId = params['sessionId']; + const key = params['key']; + const value = params['value']; + const scope = params['scope']; + if ( + params['v'] !== 1 || + typeof sessionId !== 'string' || + key !== 'model.reasoningEffort' || + typeof value !== 'string' || + (scope !== 'user' && scope !== 'workspace') + ) { + return; + } + if (!this.ownsSession(sessionId)) return; + const entry = this.resolveEntry(sessionId); + if (!entry) return; + const event = { + type: 'settings_changed', + data: { key, value, scope }, + ...(entry.activePromptOriginatorClientId + ? { originatorClientId: entry.activePromptOriginatorClientId } + : {}), + } as const; + if (this.onSettingsChanged) { + this.onSettingsChanged(event); + } else { + entry.events.publish(event); + } + } + /** * A2: promote an in-session `current_mode_update` extNotification to * `approval_mode_changed`. Uses the same suppression pattern as diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index 48337039dfa..8a29da6304b 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -17,6 +17,7 @@ import type { } from '@qwen-code/qwen-code-core'; import { MAX_SUB_SESSION_PROMPT_CHARS } from '@qwen-code/qwen-code-core/subSessionConstants'; import type { ChannelFactory } from './channel.js'; +import type { BridgeEvent } from './eventBus.js'; import type { PermissionPolicy } from './permission.js'; import type { PermissionAuditPublisher } from './permissionMediator.js'; import type { ServePreflightCell, ServeWorkspaceEnvStatus } from './status.js'; @@ -214,6 +215,13 @@ export interface BridgeTelemetry { * strictly-required field. See per-field JSDoc for caller contract. */ export interface BridgeOptions { + /** + * Process-wide event fan-out supplied by multi-workspace daemon hosts. + * The bridge uses this only for user-scoped settings, whose persisted value + * is shared by every workspace. Embedded and single-workspace callers may + * omit it and retain bridge-local delivery. + */ + publishGlobalWorkspaceEvent?: (event: Omit) => void; /** * Runtime-owned directory for persistent session attachment bytes. Daemon * callers provide a workspace-scoped directory under the Qwen runtime temp diff --git a/packages/acp-bridge/src/index.ts b/packages/acp-bridge/src/index.ts index fab71a279a0..a310da12759 100644 --- a/packages/acp-bridge/src/index.ts +++ b/packages/acp-bridge/src/index.ts @@ -18,6 +18,7 @@ export * from './bridgeTypes.js'; export * from './session-source.js'; export * from './bridgeOptions.js'; export * from './prompt-ledger.js'; +export * from './reasoning-persistence.js'; export * from './session-restore-timeout.js'; export * from './replayWindowLimits.js'; export * from './spawnChannel.js'; diff --git a/packages/acp-bridge/src/internal/testUtils.ts b/packages/acp-bridge/src/internal/testUtils.ts index 66aad392223..8a4f64d8d98 100644 --- a/packages/acp-bridge/src/internal/testUtils.ts +++ b/packages/acp-bridge/src/internal/testUtils.ts @@ -141,6 +141,10 @@ export interface FakeAgentOpts { p: ResumeSessionRequest, self: FakeAgent, ) => Promise | ResumeSessionResponse; + setSessionConfigOptionImpl?: ( + p: SetSessionConfigOptionRequest, + self: FakeAgent, + ) => Promise | SetSessionConfigOptionResponse; extMethodImpl?: ( method: string, params: Record, @@ -155,6 +159,7 @@ export class FakeAgent implements Agent { resumeSessionCalls: ResumeSessionRequest[] = []; promptCalls: PromptRequest[] = []; cancelCalls: CancelNotification[] = []; + setSessionConfigOptionCalls: SetSessionConfigOptionRequest[] = []; extMethodCalls: Array<{ method: string; params: Record }> = []; constructor(private readonly opts: FakeAgentOpts = {}) {} @@ -229,8 +234,12 @@ export class FakeAgent implements Agent { throw new Error('not implemented in test fake'); } async setSessionConfigOption( - _p: SetSessionConfigOptionRequest, + p: SetSessionConfigOptionRequest, ): Promise { + this.setSessionConfigOptionCalls.push(p); + if (this.opts.setSessionConfigOptionImpl) { + return this.opts.setSessionConfigOptionImpl(p, this); + } throw new Error('not implemented in test fake'); } async extMethod( diff --git a/packages/acp-bridge/src/reasoning-persistence.ts b/packages/acp-bridge/src/reasoning-persistence.ts new file mode 100644 index 00000000000..81eef4f7125 --- /dev/null +++ b/packages/acp-bridge/src/reasoning-persistence.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PERSIST_REASONING_EFFORT_META_KEY = + 'qwenCode/persistReasoningEffort'; +export const REASONING_EFFORT_PERSISTENCE_META_KEY = + 'qwenCode/reasoningEffortPersistence'; diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 049212e7422..ee462d90ca6 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -18,6 +18,11 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/bridgeErrors'; +import { + PERSIST_REASONING_EFFORT_META_KEY, + REASONING_EFFORT_PERSISTENCE_META_KEY, +} from '@qwen-code/acp-bridge/reasoningPersistence'; +import { REASONING_EFFORT_PERSISTENCE_ERROR_KIND } from '@qwen-code/sdk/daemon'; import { ACP_EVENT_LOOP_STALL_RESTART_MS } from '@qwen-code/channel-base'; import { getConversationDirectoryName } from '../utils/conversation-directory-identity.js'; @@ -91,6 +96,14 @@ const { mockPreloadContentGenerator } = vi.hoisted(() => ({ mockPreloadContentGenerator: vi.fn().mockResolvedValue(undefined), })); +const { mockClearIncompatibleReasoningEffortForModel } = vi.hoisted(() => ({ + mockClearIncompatibleReasoningEffortForModel: vi.fn(), +})); +vi.mock('../config/reasoning-effort-persistence.js', () => ({ + clearIncompatibleReasoningEffortForModel: + mockClearIncompatibleReasoningEffortForModel, +})); + const { mockAddDaemonRequestAttribute } = vi.hoisted(() => ({ mockAddDaemonRequestAttribute: vi.fn(), })); @@ -314,6 +327,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ return config.getReasoningEffort() === effort; }, REASONING_EFFORT_TIERS: ['low', 'medium', 'high', 'xhigh', 'max'], + isQwenFamilyWireModel: ( + await importOriginal() + ).isQwenFamilyWireModel, ApprovalMode: { YOLO: 'yolo' }, isGatedMcpScope: (scope: unknown) => scope === 'project' || scope === 'workspace', @@ -952,6 +968,7 @@ import { selectVisibleHistoryRecords, createManagedExternalToolGuard, } from './acpAgent.js'; +import { getModelConfiguration } from './model-configuration.js'; import type { Config, GoalSnapshotV2 } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; import type { CliArgs } from '../config/config.js'; @@ -2083,6 +2100,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { releaseTodoStopGuardQueuedPromptWait: ReturnType; isIdle: ReturnType; isTurnIdle: ReturnType; + persistReasoningEffort: ReturnType; getCreatedAt: ReturnType; getTurnCount: ReturnType; } @@ -2364,6 +2382,28 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('passes the live config-option builder into created sessions', async () => { + await setupSessionMocks('config-option-builder-session'); + const agentPromise = runAcpAgent( + mockConfig, + makeSessionSettings(), + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }) as AgentLike; + await agent.initialize({ clientCapabilities: {} }); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + expect(vi.mocked(Session).mock.calls.at(-1)?.[6]).toBeTypeOf('function'); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('closes managed writers before resource shutdown on connection EOF', async () => { const innerConfig = await setupSessionMocks('managed-session'); const order: string[] = []; @@ -4015,7 +4055,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }); function makeInnerConfig() { - return { + let reasoningPreference: false | string | undefined; + const config = { initialize: vi.fn().mockResolvedValue(undefined), shutdown: vi.fn().mockResolvedValue(undefined), closeSessionWriter: vi.fn().mockResolvedValue(undefined), @@ -4043,8 +4084,15 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getModes: vi.fn().mockReturnValue([]), getApprovalMode: vi.fn().mockReturnValue('default'), getReasoningEffort: vi.fn().mockReturnValue(undefined), + getReasoningPreference: vi.fn(() => reasoningPreference), getReasoningEffortOverride: vi.fn().mockReturnValue(undefined), - setReasoningEffort: vi.fn(), + setReasoningEffort: vi.fn((effort: string | undefined) => { + reasoningPreference = effort; + }), + disableReasoning: vi.fn(() => { + reasoningPreference = false; + config.getContentGeneratorConfig().reasoning = false; + }), getSessionId: vi.fn().mockReturnValue('test-session-id'), getAuthType: vi.fn().mockReturnValue('api-key'), getCurrentModelRegistryBaseUrl: vi.fn().mockReturnValue(undefined), @@ -4076,6 +4124,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { hasHooksForEvent: vi.fn().mockReturnValue(false), isTrustedFolder: vi.fn().mockReturnValue(true), }; + return config; } function expectedDefaultAcpLocalReadRoots( @@ -4357,6 +4406,10 @@ describe('QwenAgent MCP SSE/HTTP support', () => { releaseTodoStopGuardQueuedPromptWait: vi.fn().mockReturnValue(true), isIdle: vi.fn().mockReturnValue(true), isTurnIdle: vi.fn().mockReturnValue(true), + persistReasoningEffort: vi.fn((value: string | undefined) => ({ + scope: SettingScope.User, + effectiveValue: value, + })), getCreatedAt: vi.fn().mockReturnValue(1_700_000_000_000), getTurnCount: vi.fn().mockReturnValue(3), prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), @@ -7679,15 +7732,29 @@ describe('QwenAgent MCP SSE/HTTP support', () => { }); it('projects reasoning preview only for stable non-runtime qwen3.8-max', async () => { + const getReasoningPreference = vi.fn( + (): false | string | undefined => undefined, + ); + let thinkingMandatory = false; mockConfig = { ...mockConfig, getTargetDir: vi.fn().mockReturnValue('/work/status'), getAuthType: vi.fn().mockReturnValue('qwen'), getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined), getModel: vi.fn().mockReturnValue('qwen3.8-max'), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ reasoning: { effort: 'medium' } }), + getReasoningEffort: vi.fn().mockReturnValue('medium'), + getReasoningPreference, getResolvedModelConfig: vi.fn((authType: string, modelId: string) => authType === 'qwen' && modelId === 'qwen3.8-max' - ? { generationConfig: { thinkingMandatory: true } } + ? { + generationConfig: { + reasoning: { effort: 'provider.Ultra' }, + thinkingMandatory, + }, + } : undefined, ), getAllConfiguredModels: vi.fn().mockReturnValue([ @@ -7757,14 +7824,15 @@ describe('QwenAgent MCP SSE/HTTP support', () => { expect(stable?.configOptions).toMatchObject([ { id: 'reasoning_effort', - currentValue: 'xhigh', - options: [{ value: 'low' }, { value: 'medium' }, { value: 'xhigh' }], - _meta: { - 'qwenCode/reasoning': { - defaultEffort: 'xhigh', - thinkingMandatory: true, - }, - }, + currentValue: 'provider.Ultra', + options: [ + { value: 'none' }, + { value: 'default' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + { value: 'provider.Ultra', name: 'provider.Ultra' }, + ], }, ]); expect( @@ -7776,6 +7844,67 @@ describe('QwenAgent MCP SSE/HTTP support', () => { models.filter((model) => model.modelId.startsWith('qwen-route:v1:')), ).toHaveLength(2); + getReasoningPreference.mockReturnValue('medium'); + const overridden = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.workspaceProviders, + {}, + ); + const overriddenStable = ( + overridden['providers'] as Array<{ + models: Array<{ + baseModelId: string; + isRuntime: boolean; + configOptions?: Array<{ id?: string; currentValue?: string }>; + }>; + }> + ) + .flatMap((provider) => provider.models) + .find( + (model) => + model.baseModelId === 'qwen3.8-max' && model.isRuntime === false, + ); + expect(overriddenStable?.configOptions).toMatchObject([ + { id: 'reasoning_effort', currentValue: 'medium' }, + ]); + + thinkingMandatory = true; + const mandatory = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.workspaceProviders, + {}, + ); + const mandatoryStable = ( + mandatory['providers'] as Array<{ + models: Array<{ + baseModelId: string; + isRuntime: boolean; + configOptions?: unknown[]; + }>; + }> + ) + .flatMap((provider) => provider.models) + .find( + (model) => + model.baseModelId === 'qwen3.8-max' && model.isRuntime === false, + ); + expect(mandatoryStable?.configOptions).toMatchObject([ + { + id: 'reasoning_effort', + currentValue: 'medium', + options: [ + { value: 'default' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + ], + _meta: { + 'qwenCode/reasoning': { + defaultEffort: 'xhigh', + thinkingMandatory: true, + }, + }, + }, + ]); + mockConnectionState.resolve(); await agentPromise; }); @@ -7856,7 +7985,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { it('exposes and applies the ACP reasoning effort selector', async () => { const sessionId = 'reasoning-effort-session'; const innerConfig = await setupSessionMocks(sessionId); - let currentEffort: string | undefined; + let currentEffort: string | undefined = 'default'; innerConfig.getReasoningEffort = vi.fn(() => currentEffort); innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { currentEffort = effort; @@ -7888,13 +8017,14 @@ describe('QwenAgent MCP SSE/HTTP support', () => { category: 'thought_level', currentValue: 'default', options: [ + { value: 'none', name: 'Thinking off' }, { value: 'default', name: 'Default' }, { value: 'low', name: 'Low', description: expect.any(String) }, { value: 'medium', name: 'Medium', description: expect.any(String) }, { value: 'high', name: 'High', description: expect.any(String) }, { value: 'xhigh', - name: 'Extra high', + name: 'Extra High', description: expect.any(String), }, { value: 'max', name: 'Max', description: expect.any(String) }, @@ -7926,141 +8056,601 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ?.currentValue, ).toBe('default'); + const custom = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'ultra', + })) as { configOptions: typeof session.configOptions }; + expect(innerConfig.setReasoningEffort).toHaveBeenLastCalledWith('ultra'); + expect( + custom.configOptions.find((option) => option.id === 'reasoning_effort'), + ).toMatchObject({ + currentValue: 'ultra', + options: expect.arrayContaining([ + expect.objectContaining({ value: 'ultra', name: 'ultra' }), + ]), + }); + expect(innerConfig.setReasoningEffort).toHaveBeenCalledTimes(3); + + innerConfig.setReasoningEffort.mockImplementation(() => {}); await expect( agent.setSessionConfigOption({ sessionId, configId: 'reasoning_effort', - value: 'ultra', + value: 'xhigh', }), ).rejects.toThrow( - 'Unknown reasoning effort: ultra. Choose one of: default, low, medium, high, xhigh, max', + 'Reasoning effort cannot be applied while thinking is disabled', + ); + expect(innerConfig.setReasoningEffort).toHaveBeenCalledTimes(4); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('hides the ACP reasoning selector for unregistered Qwen models', async () => { + const sessionId = 'unregistered-qwen-reasoning-session'; + const innerConfig = await setupSessionMocks(sessionId); + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.7-plus-2026-05-26'); + + const { agent, agentPromise } = await bootAcpAgent(); + try { + const session = (await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + })) as { + configOptions: Array<{ id: string }>; + }; + + expect( + session.configOptions.some( + (option) => option.id === 'reasoning_effort', + ), + ).toBe(false); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('rejects a blank reasoning effort before applying or persisting it', async () => { + const sessionId = 'blank-reasoning-effort-session'; + const innerConfig = await setupSessionMocks(sessionId); + const { agent, agentPromise } = await bootAcpAgent(); + + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + + await expect( + agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: ' ', + _meta: { 'qwenCode/persistReasoningEffort': true }, + }), + ).rejects.toThrow('Reasoning effort cannot be blank'); + expect(innerConfig.setReasoningEffort).not.toHaveBeenCalled(); + expect(innerConfig.disableReasoning).not.toHaveBeenCalled(); + expect(lastSessionMock!.persistReasoningEffort).not.toHaveBeenCalled(); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('projects qwen3.8-max reasoning controls through one ACP option', async () => { + const sessionId = 'qwen38-reasoning-session'; + const innerConfig = await setupSessionMocks(sessionId); + const generation: { + reasoning?: false | { effort?: string; budget_tokens?: number }; + } = { reasoning: { effort: 'high' } }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn(() => + generation.reasoning ? generation.reasoning.effort : undefined, + ); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + generation.reasoning = effort ? { effort } : {}; + }); + + const { agent, agentPromise } = await bootAcpAgent(); + try { + const session = (await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + })) as { + configOptions: Array<{ + id: string; + currentValue: string; + options: Array<{ value: string }>; + }>; + }; + const option = session.configOptions.find( + (item) => item.id === 'reasoning_effort', + ); + expect( + session.configOptions.filter((item) => item.id === 'effort'), + ).toEqual([]); + expect(option).toMatchObject({ + currentValue: 'xhigh', + options: [ + { value: 'none' }, + { value: 'default' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + ], + }); + + const reset = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'default', + })) as SetSessionConfigOptionResponse; + expect(innerConfig.setReasoningEffort).toHaveBeenCalledWith(undefined); + expect( + reset.configOptions.find((item) => item.id === 'reasoning_effort'), + ).toMatchObject({ + currentValue: 'xhigh', + options: [ + { value: 'none' }, + { value: 'default' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + ], + }); + + const medium = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'medium', + })) as SetSessionConfigOptionResponse; + expect(generation.reasoning).toEqual({ effort: 'medium' }); + expect( + medium.configOptions.find((item) => item.id === 'reasoning_effort') + ?.currentValue, + ).toBe('medium'); + + const disabled = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'none', + })) as SetSessionConfigOptionResponse; + expect(generation.reasoning).toBe(false); + expect( + disabled.configOptions.find((item) => item.id === 'reasoning_effort') + ?.currentValue, + ).toBe('none'); + + const enabled = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'medium', + })) as SetSessionConfigOptionResponse; + expect(generation.reasoning).toEqual({ effort: 'medium' }); + expect( + enabled.configOptions.find((item) => item.id === 'reasoning_effort') + ?.currentValue, + ).toBe('medium'); + + const custom = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'high', + })) as SetSessionConfigOptionResponse; + expect(generation.reasoning).toEqual({ effort: 'high' }); + expect( + custom.configOptions.find((item) => item.id === 'reasoning_effort') + ?.currentValue, + ).toBe('xhigh'); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('persists reasoning only for the private REST marker', async () => { + const sessionId = 'persistent-reasoning-session'; + const innerConfig = await setupSessionMocks(sessionId); + const generation: { + reasoning?: false | { effort?: string }; + } = { reasoning: { effort: 'medium' } }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn(() => + generation.reasoning ? generation.reasoning.effort : undefined, + ); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + generation.reasoning = effort ? { effort } : undefined; + }); + + const { agent, agentPromise } = await bootAcpAgent(); + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + const persistReasoningEffort = lastSessionMock!.persistReasoningEffort; + + await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'xhigh', + }); + expect(persistReasoningEffort).not.toHaveBeenCalled(); + + const persisted = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'medium', + _meta: { [PERSIST_REASONING_EFFORT_META_KEY]: true }, + })) as SetSessionConfigOptionResponse; + expect(persistReasoningEffort).toHaveBeenLastCalledWith('medium'); + expect(persisted._meta).toEqual({ + [REASONING_EFFORT_PERSISTENCE_META_KEY]: { scope: 'user' }, + }); + + await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'default', + _meta: { 'qwenCode/persistReasoningEffort': true }, + }); + expect(persistReasoningEffort).toHaveBeenLastCalledWith(undefined); + expect(generation.reasoning).toBeUndefined(); + + await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'none', + _meta: { 'qwenCode/persistReasoningEffort': true }, + }); + expect(persistReasoningEffort).toHaveBeenLastCalledWith('none'); + expect(generation.reasoning).toBe(false); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('reports when a persisted effort is shadowed without reverting the live session', async () => { + const sessionId = 'shadowed-reasoning-effort-session'; + const innerConfig = await setupSessionMocks(sessionId); + const generation: { reasoning?: { effort?: string } } = { + reasoning: { effort: 'medium' }, + }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn(() => generation.reasoning?.effort); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + generation.reasoning = effort ? { effort } : undefined; + }); + + const { agent, agentPromise } = await bootAcpAgent(); + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + lastSessionMock!.persistReasoningEffort.mockImplementationOnce(() => { + throw RequestError.internalError( + { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + 'Reasoning effort is active for the current session, but the default could not be saved: the selected value is shadowed by a higher-precedence setting', + ); + }); + + await expect( + agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'high', + _meta: { 'qwenCode/persistReasoningEffort': true }, + }), + ).rejects.toMatchObject({ + code: -32603, + data: { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + message: expect.stringContaining( + 'shadowed by a higher-precedence setting', + ), + }); + expect(lastSessionMock!.persistReasoningEffort).toHaveBeenCalledWith( + 'high', + ); + expect(generation.reasoning).toEqual({ effort: 'high' }); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('aligns a cleared workspace override to the inherited persisted preference', async () => { + const sessionId = 'layered-reasoning-session'; + const innerConfig = await setupSessionMocks(sessionId); + const generation: { + reasoning?: false | { effort?: string }; + thinkingMandatory?: boolean; + } = { reasoning: false }; + let preference: false | string | undefined = false; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningPreference = vi.fn(() => preference); + innerConfig.getReasoningEffort = vi.fn(() => + generation.reasoning ? generation.reasoning.effort : undefined, + ); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + preference = effort; + generation.reasoning = effort + ? { effort } + : { effort: 'provider-default' }; + }); + innerConfig.disableReasoning = vi.fn(() => { + preference = false; + generation.reasoning = false; + }); + + const { agent, agentPromise } = await bootAcpAgent(); + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + lastSessionMock!.persistReasoningEffort.mockReturnValueOnce({ + scope: SettingScope.Workspace, + effectiveValue: 'medium', + }); + + const inherited = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'default', + _meta: { 'qwenCode/persistReasoningEffort': true }, + })) as SetSessionConfigOptionResponse; + + expect(generation.reasoning).toEqual({ effort: 'medium' }); + expect( + inherited.configOptions.find( + (option) => option.id === 'reasoning_effort', + )?.currentValue, + ).toBe('medium'); + expect(inherited._meta).toEqual({ + [REASONING_EFFORT_PERSISTENCE_META_KEY]: { scope: 'workspace' }, + }); + + lastSessionMock!.persistReasoningEffort.mockReturnValueOnce({ + scope: SettingScope.Workspace, + effectiveValue: 'default', + }); + await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'default', + _meta: { 'qwenCode/persistReasoningEffort': true }, + }); + + expect(generation.reasoning).toEqual({ effort: 'default' }); + expect(innerConfig.setReasoningEffort).toHaveBeenLastCalledWith( + 'default', ); - expect(innerConfig.setReasoningEffort).toHaveBeenCalledTimes(2); - innerConfig.setReasoningEffort.mockImplementation(() => {}); + generation.thinkingMandatory = true; + lastSessionMock!.persistReasoningEffort.mockReturnValueOnce({ + scope: SettingScope.Workspace, + effectiveValue: 'none', + }); + const mandatoryInherited = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'default', + _meta: { 'qwenCode/persistReasoningEffort': true }, + })) as SetSessionConfigOptionResponse; + + expect(innerConfig.disableReasoning).not.toHaveBeenCalled(); + expect(generation.reasoning).toEqual({ effort: 'provider-default' }); + expect(mandatoryInherited._meta).toEqual({ + [REASONING_EFFORT_PERSISTENCE_META_KEY]: { scope: 'workspace' }, + }); + + generation.thinkingMandatory = false; + lastSessionMock!.persistReasoningEffort.mockReturnValueOnce({ + scope: SettingScope.Workspace, + effectiveValue: 'none', + }); + await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'default', + _meta: { 'qwenCode/persistReasoningEffort': true }, + }); + + expect(innerConfig.disableReasoning).toHaveBeenCalledOnce(); + expect(generation.reasoning).toBe(false); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('applies and persists an advertised custom effort value', async () => { + const reasoning = getModelConfiguration('qwen3.8-max')?.reasoning; + if (!reasoning || reasoning.toggleOnly) { + throw new Error('Expected tiered qwen3.8-max reasoning configuration'); + } + const mutable = reasoning as unknown as { + efforts: Array<{ value: string; name: string }>; + }; + + const sessionId = 'custom-reasoning-session'; + const innerConfig = await setupSessionMocks(sessionId); + const generation: { reasoning?: { effort?: string } } = { + reasoning: { effort: 'medium' }, + }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn(() => generation.reasoning?.effort); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + generation.reasoning = effort ? { effort } : undefined; + }); + + const { agent, agentPromise } = await bootAcpAgent(); + try { + mutable.efforts.push({ value: 'ultra', name: 'Ultra thinking' }); + const session = (await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + })) as { + configOptions: Array<{ + id: string; + options: Array<{ value: string; name: string }>; + }>; + }; + expect( + session.configOptions.find((option) => option.id === 'reasoning_effort') + ?.options, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: 'ultra', + name: 'Ultra thinking', + }), + ]), + ); + + await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'ultra', + _meta: { 'qwenCode/persistReasoningEffort': true }, + }); + + expect(generation.reasoning).toEqual({ effort: 'ultra' }); + expect(lastSessionMock!.persistReasoningEffort).toHaveBeenCalledWith( + 'ultra', + ); + } finally { + mutable.efforts.pop(); + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('keeps the selected live reasoning when persistence fails', async () => { + const sessionId = 'reasoning-write-failure-session'; + const innerConfig = await setupSessionMocks(sessionId); + const generation = { reasoning: { effort: 'medium' } }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn(() => generation.reasoning.effort); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + generation.reasoning = { effort: effort ?? '' }; + }); + + const { agent, agentPromise } = await bootAcpAgent(); + try { + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); + lastSessionMock!.persistReasoningEffort.mockImplementationOnce(() => { + throw new Error( + 'Reasoning effort is active for the current session, but the default could not be saved: disk full', + ); + }); + await expect( agent.setSessionConfigOption({ sessionId, configId: 'reasoning_effort', value: 'xhigh', + _meta: { 'qwenCode/persistReasoningEffort': true }, }), - ).rejects.toThrow( - 'Reasoning effort cannot be applied while thinking is disabled', - ); - expect(innerConfig.setReasoningEffort).toHaveBeenCalledTimes(3); + ).rejects.toThrow('default could not be saved: disk full'); + expect(generation.reasoning).toEqual({ effort: 'xhigh' }); } finally { mockConnectionState.resolve(); await agentPromise; } }); - it('projects qwen3.8-max reasoning controls through one ACP option', async () => { - const sessionId = 'qwen38-reasoning-session'; + it('makes qwen3.8-max effort mutable over a static request override', async () => { + const sessionId = 'qwen38-static-reasoning-override'; const innerConfig = await setupSessionMocks(sessionId); const generation: { - reasoning?: false | { effort?: string; budget_tokens?: number }; - } = { reasoning: { effort: 'high' } }; + reasoning?: false | { effort?: string }; + samplingParams?: Record; + } = { + reasoning: false, + samplingParams: { reasoning_effort: 'high' }, + }; innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); innerConfig.getContentGeneratorConfig = vi.fn(() => generation); innerConfig.getReasoningEffort = vi.fn(() => generation.reasoning ? generation.reasoning.effort : undefined, ); innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { - generation.reasoning = effort ? { effort } : {}; + generation.reasoning = effort ? { effort } : undefined; }); + innerConfig.getReasoningEffortOverride = vi.fn(() => + generation.samplingParams?.['reasoning_effort'] === undefined + ? undefined + : { + source: 'samplingParams' as const, + field: 'reasoning_effort' as const, + }, + ); const { agent, agentPromise } = await bootAcpAgent(); try { const session = (await agent.newSession({ cwd: '/tmp', mcpServers: [], - })) as { - configOptions: Array<{ - id: string; - currentValue: string; - options: Array<{ value: string }>; - }>; - }; - const option = session.configOptions.find( - (item) => item.id === 'reasoning_effort', - ); + })) as SetSessionConfigOptionResponse; expect( - session.configOptions.filter((item) => item.id === 'effort'), - ).toEqual([]); - expect(option).toMatchObject({ - currentValue: 'xhigh', + session.configOptions.find((item) => item.id === 'reasoning_effort'), + ).toMatchObject({ + currentValue: 'none', options: [ { value: 'none' }, + { value: 'default' }, { value: 'low' }, { value: 'medium' }, { value: 'xhigh' }, ], }); - const medium = (await agent.setSessionConfigOption({ - sessionId, - configId: 'reasoning_effort', - value: 'medium', - })) as SetSessionConfigOptionResponse; - expect(generation.reasoning).toEqual({ effort: 'medium' }); - expect( - medium.configOptions.find((item) => item.id === 'reasoning_effort') - ?.currentValue, - ).toBe('medium'); - - const disabled = (await agent.setSessionConfigOption({ - sessionId, - configId: 'reasoning_effort', - value: 'none', - })) as SetSessionConfigOptionResponse; - expect(generation.reasoning).toBe(false); - expect( - disabled.configOptions.find((item) => item.id === 'reasoning_effort') - ?.currentValue, - ).toBe('none'); - - const enabled = (await agent.setSessionConfigOption({ + const selected = (await agent.setSessionConfigOption({ sessionId, configId: 'reasoning_effort', value: 'medium', })) as SetSessionConfigOptionResponse; + expect(generation.samplingParams).toEqual({}); expect(generation.reasoning).toEqual({ effort: 'medium' }); expect( - enabled.configOptions.find((item) => item.id === 'reasoning_effort') + selected.configOptions.find((item) => item.id === 'reasoning_effort') ?.currentValue, ).toBe('medium'); - - await expect( - agent.setSessionConfigOption({ - sessionId, - configId: 'reasoning_effort', - value: 'high', - }), - ).rejects.toThrow( - 'Unknown reasoning effort: high. Choose one of: none, low, medium, xhigh', - ); - expect(generation.reasoning).toEqual({ effort: 'medium' }); } finally { mockConnectionState.resolve(); await agentPromise; } }); - it('makes qwen3.8-max effort mutable over a static request override', async () => { - const sessionId = 'qwen38-static-reasoning-override'; + it('restores a qwen3.8-max static default after clearing an explicit effort', async () => { + const sessionId = 'qwen38-restore-static-reasoning-default'; const innerConfig = await setupSessionMocks(sessionId); + const baselineSamplingParams = { + reasoning_effort: 'high', + temperature: 0.2, + }; const generation: { reasoning?: false | { effort?: string }; samplingParams?: Record; } = { - reasoning: false, - samplingParams: { reasoning_effort: 'high' }, + reasoning: { effort: 'xhigh' }, + samplingParams: baselineSamplingParams, }; innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); innerConfig.getContentGeneratorConfig = vi.fn(() => generation); innerConfig.getReasoningEffort = vi.fn(() => generation.reasoning ? generation.reasoning.effort : undefined, ); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + if (effort) { + generation.reasoning = { effort }; + return; + } + generation.reasoning = { effort: 'xhigh' }; + generation.samplingParams = { ...baselineSamplingParams }; + }); innerConfig.getReasoningEffortOverride = vi.fn(() => generation.samplingParams?.['reasoning_effort'] === undefined ? undefined @@ -8072,33 +8662,34 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const { agent, agentPromise } = await bootAcpAgent(); try { - const session = (await agent.newSession({ - cwd: '/tmp', - mcpServers: [], - })) as SetSessionConfigOptionResponse; - expect( - session.configOptions.find((item) => item.id === 'reasoning_effort'), - ).toMatchObject({ - currentValue: 'none', - options: [ - { value: 'none' }, - { value: 'low' }, - { value: 'medium' }, - { value: 'xhigh' }, - ], - }); + await agent.newSession({ cwd: '/tmp', mcpServers: [] }); - const selected = (await agent.setSessionConfigOption({ + await agent.setSessionConfigOption({ sessionId, configId: 'reasoning_effort', value: 'medium', + }); + expect(generation.samplingParams).toEqual({ temperature: 0.2 }); + + const cleared = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'default', })) as SetSessionConfigOptionResponse; - expect(generation.samplingParams).toEqual({}); - expect(generation.reasoning).toEqual({ effort: 'medium' }); + + expect(generation.reasoning).toEqual({ effort: 'xhigh' }); + expect(generation.samplingParams).toEqual({ + reasoning_effort: 'high', + temperature: 0.2, + }); + expect(baselineSamplingParams).toEqual({ + reasoning_effort: 'high', + temperature: 0.2, + }); expect( - selected.configOptions.find((item) => item.id === 'reasoning_effort') + cleared.configOptions.find((item) => item.id === 'reasoning_effort') ?.currentValue, - ).toBe('medium'); + ).toBe('xhigh'); } finally { mockConnectionState.resolve(); await agentPromise; @@ -8166,6 +8757,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfig.getReasoningEffort = vi.fn(() => generation.reasoning ? generation.reasoning.effort : undefined, ); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + generation.reasoning = effort ? { effort } : undefined; + }); innerConfig.getReasoningEffortOverride = vi.fn(() => generation.extra_body?.['enable_thinking'] === false ? { @@ -8192,6 +8786,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { currentValue: 'none', options: [ { value: 'none' }, + { value: 'default' }, { value: 'low' }, { value: 'medium' }, { value: 'xhigh' }, @@ -8310,6 +8905,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ).toMatchObject({ currentValue: 'default', options: [ + { value: 'none' }, { value: 'default' }, { value: 'low' }, { value: 'medium' }, @@ -8342,6 +8938,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfig.getReasoningEffort = vi.fn(() => generation.reasoning ? generation.reasoning.effort : undefined, ); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + generation.reasoning = effort ? { effort } : undefined; + }); const { agent, agentPromise } = await bootAcpAgent(); try { @@ -8389,15 +8988,16 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ?.currentValue, ).toBe('default'); - await expect( - agent.setSessionConfigOption({ - sessionId, - configId: 'reasoning_effort', - value: 'low', - }), - ).rejects.toThrow( - 'Unknown reasoning effort: low. Choose one of: none, default', - ); + const custom = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'low', + })) as SetSessionConfigOptionResponse; + expect(generation.reasoning).toEqual({ effort: 'low' }); + expect( + custom.configOptions.find((item) => item.id === 'reasoning_effort') + ?.currentValue, + ).toBe('low'); } finally { mockConnectionState.resolve(); await agentPromise; @@ -8414,6 +9014,9 @@ describe('QwenAgent MCP SSE/HTTP support', () => { innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); innerConfig.getContentGeneratorConfig = vi.fn(() => generation); innerConfig.getReasoningEffort = vi.fn(() => generation.reasoning.effort); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + generation.reasoning = { effort: effort ?? 'xhigh' }; + }); const { agent, agentPromise } = await bootAcpAgent(); try { @@ -8432,7 +9035,12 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ); expect(option).toMatchObject({ currentValue: 'medium', - options: [{ value: 'low' }, { value: 'medium' }, { value: 'xhigh' }], + options: [ + { value: 'default' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + ], _meta: { 'qwenCode/reasoning': { defaultEffort: 'xhigh', @@ -8458,9 +9066,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { configId: 'reasoning_effort', value: 'none', }), - ).rejects.toThrow( - 'Unknown reasoning effort: none. Choose one of: low, medium, xhigh', - ); + ).rejects.toThrow('Reasoning cannot be disabled for this model'); expect(generation.reasoning).toEqual({ effort: 'xhigh' }); } finally { mockConnectionState.resolve(); @@ -8540,6 +9146,52 @@ describe('QwenAgent MCP SSE/HTTP support', () => { } }); + it('reports the effective default when persisted disable reaches a mandatory model', async () => { + const sessionId = 'mandatory-thinking-disabled-preference-session'; + const innerConfig = await setupSessionMocks(sessionId); + const generation = { + reasoning: false, + thinkingMandatory: true, + } as const; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningPreference = vi.fn().mockReturnValue(false); + innerConfig.getReasoningEffort = vi.fn().mockReturnValue(undefined); + + const { agent, agentPromise } = await bootAcpAgent(); + try { + const session = (await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + })) as { + configOptions: Array<{ + id: string; + currentValue: string; + options: Array<{ value: string }>; + }>; + }; + const option = session.configOptions.find( + (item) => item.id === 'reasoning_effort', + ); + expect(option?.currentValue).toBe('xhigh'); + expect(option?.options.map(({ value }) => value)).toContain('default'); + expect(option?.options.map(({ value }) => value)).not.toContain('none'); + + await expect( + agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'none', + }), + ).rejects.toThrow('Reasoning cannot be disabled for this model'); + expect(generation.reasoning).toBe(false); + expect(innerConfig.disableReasoning).not.toHaveBeenCalled(); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + it.each([ { description: 'hide discontinued qwen-oauth for other auth types', @@ -23133,6 +23785,85 @@ describe('sessionLanguage multi-session propagation', () => { await agentPromise; }); + it('clears an incompatible live effort after a workspace-reload model switch', async () => { + let mergedSettings: Record = { + model: { name: 'qwen3.8-max', reasoningEffort: 'ultra' }, + }; + const settings = { + get merged() { + return mergedSettings; + }, + reloadScopeFromDisk: vi.fn(() => { + mergedSettings = { + model: { name: 'qwen3.7-max', reasoningEffort: 'ultra' }, + }; + }), + getUserHooks: vi.fn().mockReturnValue({}), + getProjectHooks: vi.fn().mockReturnValue({}), + } as unknown as LoadedSettings; + let model = 'qwen3.8-max'; + const cfg = makeConfig({ + getSessionId: vi.fn().mockReturnValue('s-model-reload'), + getAuthType: vi.fn().mockReturnValue('openai'), + getModel: vi.fn(() => model), + getModelRouteIdentity: vi.fn(() => `${model}@route`), + getReasoningPreference: vi.fn().mockReturnValue('ultra'), + setReasoningEffort: vi.fn(), + disableReasoning: vi.fn(), + switchModel: vi.fn().mockImplementation(async (_auth, nextModel) => { + model = nextModel as string; + }), + getContentGeneratorConfig: vi.fn(() => ({ model })), + }); + + vi.mocked(loadSettings).mockReturnValue(settings); + vi.mocked(loadCliConfig).mockResolvedValue(cfg as unknown as Config); + vi.mocked(Session).mockImplementation( + () => + ({ + getId: vi.fn().mockReturnValue('s-model-reload'), + shouldHintAskUserQuestionRestore: vi.fn().mockReturnValue(false), + getConfig: vi.fn().mockReturnValue(cfg), + isIdle: vi.fn().mockReturnValue(true), + sendAvailableCommandsUpdate: vi.fn().mockResolvedValue(undefined), + installRewriter: vi.fn(), + installGoalTerminalObserver: vi.fn(), + startCronScheduler: vi.fn(), + dispose: vi.fn(), + }) as unknown as InstanceType, + ); + vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValue({ + availableCommands: [], + availableSkills: [], + }); + + const agentPromise = runAcpAgent( + makeConfig() as unknown as Config, + settings, + mockArgv, + ); + await vi.waitFor(() => expect(capturedAgentFactory).toBeDefined()); + const agent = capturedAgentFactory!({ + get closed() { + return mockConnectionState.promise; + }, + }); + + await agent.newSession({ cwd: '/reload', mcpServers: [] }); + await agent.extMethod(SERVE_CONTROL_EXT_METHODS.workspaceReload, {}); + + expect(mockClearIncompatibleReasoningEffortForModel).toHaveBeenCalledWith( + cfg, + settings, + 'qwen3.7-max', + false, + 'qwen3.8-max@route', + ); + + mockConnectionState.resolve(); + await agentPromise; + }); + it('clears Todo Stop Guard trust when workspace reload enters plan mode', async () => { let mergedSettings: Record = { tools: { approvalMode: 'default' }, diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 821df1920fb..fe9cc26fb83 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -95,8 +95,8 @@ import { normalizeSnapshotPayload, startEventLoopLagMonitor, refreshMemoryInstruction, - applyReasoningEffort, REASONING_EFFORT_TIERS, + isQwenFamilyWireModel, addDaemonRequestAttribute, extractDaemonTraceContext, withDaemonSpan, @@ -146,6 +146,10 @@ import { PROTOCOL_VERSION, } from '@agentclientprotocol/sdk'; import { isNotCurrentlyGeneratingCancelError } from '@qwen-code/acp-bridge/bridgeErrors'; +import { + PERSIST_REASONING_EFFORT_META_KEY, + REASONING_EFFORT_PERSISTENCE_META_KEY, +} from '@qwen-code/acp-bridge/reasoningPersistence'; import type { Content } from '@google/genai'; import type { Agent, @@ -224,6 +228,7 @@ import { type PermissionRuleSet, } from '../config/permission-settings.js'; import { createLoadedSettingsAdapter } from '../config/loadedSettingsAdapter.js'; +import { clearIncompatibleReasoningEffortForModel } from '../config/reasoning-effort-persistence.js'; import { isCompatibleLiveSessionSource } from '../runtime/live-session-source.js'; import { getConversationDirectoryName, @@ -267,6 +272,7 @@ import { REASONING_EFFORT_DEFAULT, REASONING_EFFORT_NAMES, REASONING_EFFORT_NONE, + resolveReasoningPreviewState, type ModelReasoningConfiguration, } from './model-configuration.js'; import { @@ -5033,8 +5039,12 @@ class QwenAgent implements Agent { try { if (!provisionalStandalone) { await profiler.time('restore_session_model', () => - restoreSessionModelThenAuthenticate(config, projection, () => - profiler.time('auth', () => this.ensureAuthenticated(config)), + restoreSessionModelThenAuthenticate( + config, + projection, + () => + profiler.time('auth', () => this.ensureAuthenticated(config)), + settings, ), ); profiler.timeSync('file_system_setup', () => @@ -5055,6 +5065,7 @@ class QwenAgent implements Agent { profiler.time('auth', () => this.ensureAuthenticated(config), ), + settings, ), ), } @@ -5386,8 +5397,12 @@ class QwenAgent implements Agent { try { if (!provisionalStandalone) { await profiler.time('restore_session_model', () => - restoreSessionModelThenAuthenticate(config, projection, () => - profiler.time('auth', () => this.ensureAuthenticated(config)), + restoreSessionModelThenAuthenticate( + config, + projection, + () => + profiler.time('auth', () => this.ensureAuthenticated(config)), + settings, ), ); profiler.timeSync('file_system_setup', () => @@ -5408,6 +5423,7 @@ class QwenAgent implements Agent { profiler.time('auth', () => this.ensureAuthenticated(config), ), + settings, ), ), } @@ -5593,6 +5609,7 @@ class QwenAgent implements Agent { } return this.runInSessionContext(session, async () => { + let responseMeta: SetSessionConfigOptionResponse['_meta']; switch (configId) { case 'mode': { await this.setSessionMode({ @@ -5612,72 +5629,86 @@ class QwenAgent implements Agent { break; } case 'reasoning_effort': { - const generation = session.getConfig().getContentGeneratorConfig(); - const thinkingMandatory = generation.thinkingMandatory === true; - const modelReasoning = this.getModelReasoningConfiguration( - session.getConfig(), - ); - if (modelReasoning) { - const effortValues = modelReasoning.toggleOnly + const persistReasoningEffort = + params._meta?.[PERSIST_REASONING_EFFORT_META_KEY] === true; + const config = session.getConfig(); + const generation = config.getContentGeneratorConfig(); + const canDisableReasoning = generation.thinkingMandatory !== true; + const selected: string | undefined = + typeof value !== 'string' ? undefined - : modelReasoning.efforts; - const selected = - value === REASONING_EFFORT_NONE && !thinkingMandatory - ? REASONING_EFFORT_NONE - : modelReasoning.toggleOnly - ? value === REASONING_EFFORT_DEFAULT - ? REASONING_EFFORT_DEFAULT - : undefined - : effortValues?.find((effort) => effort === value); - if (!selected) { - const choices = [ - ...(thinkingMandatory ? [] : [REASONING_EFFORT_NONE]), - ...(effortValues ?? [REASONING_EFFORT_DEFAULT]), - ]; + : value === REASONING_EFFORT_NONE + ? canDisableReasoning + ? REASONING_EFFORT_NONE + : undefined + : value === REASONING_EFFORT_DEFAULT + ? REASONING_EFFORT_DEFAULT + : value.trim() + ? value + : undefined; + if (!selected) { + throw RequestError.invalidParams( + undefined, + value === REASONING_EFFORT_NONE + ? 'Reasoning cannot be disabled for this model' + : 'Reasoning effort cannot be blank', + ); + } + const modelReasoning = this.getModelReasoningConfiguration(config); + if (selected === REASONING_EFFORT_NONE) { + config.disableReasoning(); + } else { + const effort = + selected === REASONING_EFFORT_DEFAULT ? undefined : selected; + if (effort && config.getReasoningPreference() === false) { + config.setReasoningEffort(undefined); + } + config.setReasoningEffort(effort); + if (effort && config.getReasoningEffort() !== effort) { throw RequestError.invalidParams( undefined, - `Unknown reasoning effort: ${value}. Choose one of: ${choices.join(', ')}`, + 'Reasoning effort cannot be applied while thinking is disabled', ); } - if (!modelReasoning.toggleOnly) { - for (const source of ['extra_body', 'samplingParams'] as const) { - const layer = generation[source]; - if (!layer) continue; - const next = { ...layer }; - delete next['enable_thinking']; - delete next['reasoning_effort']; - delete next['thinking_budget']; - generation[source] = next; - } - } - if (selected === REASONING_EFFORT_NONE) { - generation.reasoning = false; - } else if (selected === REASONING_EFFORT_DEFAULT) { - generation.reasoning = undefined; - } else { - const current = generation.reasoning; - generation.reasoning = { - ...(current || {}), - effort: selected, - }; - } - break; } - const effort = - value === REASONING_EFFORT_DEFAULT - ? undefined - : REASONING_EFFORT_TIERS.find((tier) => tier === value); - if (value !== REASONING_EFFORT_DEFAULT && effort === undefined) { - throw RequestError.invalidParams( - undefined, - `Unknown reasoning effort: ${value}. Choose one of: ${REASONING_EFFORT_DEFAULT}, ${REASONING_EFFORT_TIERS.join(', ')}`, - ); + if ( + selected !== REASONING_EFFORT_DEFAULT && + modelReasoning && + !modelReasoning.toggleOnly + ) { + for (const source of ['extra_body', 'samplingParams'] as const) { + const layer = generation[source]; + if (!layer) continue; + const next = { ...layer }; + delete next['enable_thinking']; + delete next['reasoning_effort']; + delete next['thinking_budget']; + generation[source] = next; + } } - if (!applyReasoningEffort(session.getConfig(), effort)) { - throw RequestError.invalidParams( - undefined, - 'Reasoning effort cannot be applied while thinking is disabled', + if (persistReasoningEffort) { + const persistence = session.persistReasoningEffort( + selected === REASONING_EFFORT_DEFAULT ? undefined : selected, ); + if (selected === REASONING_EFFORT_DEFAULT) { + const effectiveValue = persistence.effectiveValue; + if ( + effectiveValue === REASONING_EFFORT_NONE && + canDisableReasoning + ) { + config.disableReasoning(); + } else if (effectiveValue !== REASONING_EFFORT_NONE) { + config.setReasoningEffort(effectiveValue); + } + } + responseMeta = { + [REASONING_EFFORT_PERSISTENCE_META_KEY]: { + scope: + persistence.scope === SettingScope.Workspace + ? 'workspace' + : 'user', + }, + }; } break; } @@ -5690,6 +5721,7 @@ class QwenAgent implements Agent { return { configOptions: this.buildConfigOptions(session.getConfig()), + ...(responseMeta ? { _meta: responseMeta } : {}), }; }); } @@ -6895,6 +6927,7 @@ class QwenAgent implements Agent { : config.getCurrentModelRegistryBaseUrl?.(), ) : undefined; + const reasoningPreference = config.getReasoningPreference?.(); const providers = new Map(); for (const option of modelOptions) { @@ -6914,16 +6947,21 @@ class QwenAgent implements Agent { const isCurrent = currentAuth === model.authType && currentAcpModelId === modelId; + const resolvedGeneration = config.getResolvedModelConfig?.( + model.authType, + model.id, + model.registryBaseUrl ?? model.baseUrl, + )?.generationConfig; const configOptions = model.isRuntimeModel || modelId.startsWith(ACP_ROUTE_ID_PREFIX) ? undefined : buildModelReasoningConfigPreview(model.id, { + ...resolveReasoningPreviewState( + reasoningPreference, + resolvedGeneration?.reasoning, + ), thinkingMandatory: - config.getResolvedModelConfig?.( - model.authType, - model.id, - model.registryBaseUrl ?? model.baseUrl, - )?.generationConfig.thinkingMandatory === true, + resolvedGeneration?.thinkingMandatory === true, }); const providerModel: ServeWorkspaceProviderModel = { modelId, @@ -12042,7 +12080,16 @@ class QwenAgent implements Agent { authType ) { try { + const previousModelRouteIdentity = + config.getModelRouteIdentity?.(); await config.switchModel(authType, newModelName); + clearIncompatibleReasoningEffortForModel( + config, + this.settings, + config.getContentGeneratorConfig?.()?.model ?? newModelName, + false, + previousModelRouteIdentity, + ); } catch (err) { debugLogger.warn( `reload: switchModel failed for session ${id}: ${err}`, @@ -12747,6 +12794,7 @@ class QwenAgent implements Agent { settings, (operation) => this.runExclusiveHistoryMutation(sessionId, operation), () => this.activeWorkReporter?.notifyChanged(), + (activeConfig) => this.buildConfigOptions(activeConfig), ); const replaySessionHistory = async () => { if ( @@ -12985,12 +13033,28 @@ class QwenAgent implements Agent { options: configModelOptions, }; + if ( + isQwenFamilyWireModel(rawCurrentModelId) && + getModelConfiguration(rawCurrentModelId)?.reasoning?.thinking !== true + ) { + return [modeConfigOption, modelConfigOption]; + } + const generation = config.getContentGeneratorConfig(); const modelReasoning = this.getModelReasoningConfiguration( config, currentModelId, ); const currentModelEffort = config.getReasoningEffort?.(); + const canDisableReasoning = generation.thinkingMandatory !== true; + const reasoningDisabled = + canDisableReasoning && generation.reasoning === false; + const genericCustomEffort = + currentModelEffort && + currentModelEffort !== REASONING_EFFORT_DEFAULT && + !REASONING_EFFORT_TIERS.some((effort) => effort === currentModelEffort) + ? currentModelEffort + : undefined; const reasoningOverride = config.getReasoningEffortOverride?.(); const reasoningOverrideValue = reasoningOverride ? generation[reasoningOverride.source]?.[reasoningOverride.field] @@ -13034,8 +13098,8 @@ class QwenAgent implements Agent { ? modelReasoning.defaultEffort : normalizedOverrideEffort ? (modelReasoning.efforts.find( - (effort) => effort === normalizedOverrideEffort, - ) ?? modelReasoning.defaultEffort) + (effort) => effort.value === normalizedOverrideEffort, + )?.value ?? modelReasoning.defaultEffort) : currentModelEffort : currentModelEffort; const reasoningEnabled = @@ -13053,8 +13117,19 @@ class QwenAgent implements Agent { description: 'How hard reasoning-capable models should think', category: 'thought_level', type: 'select' as const, - currentValue: currentModelEffort ?? REASONING_EFFORT_DEFAULT, + currentValue: reasoningDisabled + ? REASONING_EFFORT_NONE + : (currentModelEffort ?? REASONING_EFFORT_DEFAULT), options: [ + ...(canDisableReasoning + ? [ + { + value: REASONING_EFFORT_NONE, + name: 'Thinking off', + description: 'Disable thinking for this session', + }, + ] + : []), { value: REASONING_EFFORT_DEFAULT, name: 'Default', @@ -13066,6 +13141,15 @@ class QwenAgent implements Agent { description: 'Providers map or clamp the requested tier for the active model', })), + ...(genericCustomEffort + ? [ + { + value: genericCustomEffort, + name: genericCustomEffort, + description: 'Use the configured model-specific effort', + }, + ] + : []), ], }; @@ -13091,7 +13175,10 @@ class QwenAgent implements Agent { return undefined; } const reasoning = getModelConfiguration(config.getModel())?.reasoning; - return reasoning?.thinking ? reasoning : undefined; + if (!reasoning?.thinking) return undefined; + return reasoning.toggleOnly && config.getReasoningEffort?.() + ? undefined + : reasoning; } private buildSelectableModelOptions(config: Config) { diff --git a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts index 9fab2150d42..3e2534135eb 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -137,6 +137,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ return config.getReasoningEffort() === effort; }, REASONING_EFFORT_TIERS: ['low', 'medium', 'high', 'xhigh', 'max'], + isQwenFamilyWireModel: vi.fn().mockReturnValue(false), DEFAULT_STOP_HOOK_BLOCK_CAP: 8, DEFAULT_MAX_SUBAGENT_DEPTH: 5, DEFAULT_MAX_TOOL_CALLS_PER_TURN: 100, diff --git a/packages/cli/src/acp-integration/model-configuration.test.ts b/packages/cli/src/acp-integration/model-configuration.test.ts index 42ab0b4dfcc..91402ba9343 100644 --- a/packages/cli/src/acp-integration/model-configuration.test.ts +++ b/packages/cli/src/acp-integration/model-configuration.test.ts @@ -9,6 +9,7 @@ import { buildModelReasoningConfigOption, buildModelReasoningConfigPreview, getModelConfiguration, + resolveReasoningPreviewState, } from './model-configuration.js'; describe('model configuration manifest', () => { @@ -16,7 +17,11 @@ describe('model configuration manifest', () => { expect(getModelConfiguration('qwen3.8-max')).toEqual({ reasoning: { thinking: true, - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ], defaultEffort: 'xhigh', }, }); @@ -28,6 +33,7 @@ describe('model configuration manifest', () => { currentValue: 'xhigh', options: [ { value: 'none' }, + { value: 'default', name: 'Default' }, { value: 'low' }, { value: 'medium' }, { value: 'xhigh' }, @@ -45,7 +51,12 @@ describe('model configuration manifest', () => { }), ).toMatchObject({ currentValue: 'xhigh', - options: [{ value: 'low' }, { value: 'medium' }, { value: 'xhigh' }], + options: [ + { value: 'default' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + ], _meta: { 'qwenCode/reasoning': { defaultEffort: 'xhigh', @@ -82,6 +93,120 @@ describe('model configuration manifest', () => { ]); }); + it('projects the loaded reasoning effort in workspace preview', () => { + expect( + buildModelReasoningConfigPreview('qwen3.8-max', { + enabled: true, + effort: 'medium', + }), + ).toMatchObject([{ currentValue: 'medium' }]); + }); + + it('projects disabled reasoning in workspace preview', () => { + expect( + buildModelReasoningConfigPreview('qwen3.8-max', { + enabled: false, + effort: 'medium', + }), + ).toMatchObject([{ currentValue: 'none' }]); + }); + + it('projects a model-default none effort as disabled reasoning', () => { + const state = resolveReasoningPreviewState(undefined, { effort: 'none' }); + + expect(state).toEqual({ enabled: false }); + expect( + buildModelReasoningConfigPreview('qwen3.8-max', state), + ).toMatchObject([{ currentValue: 'none' }]); + }); + + it('ignores a malformed non-string model-default effort', () => { + expect( + resolveReasoningPreviewState(undefined, { effort: 3 as never }), + ).toBeUndefined(); + }); + + it.each(['None', ' none '])( + 'keeps the model-default effort %j opaque', + (effort) => { + expect(resolveReasoningPreviewState(undefined, { effort })).toEqual({ + enabled: true, + effort, + }); + }, + ); + + it('ignores a malformed non-string live effort', () => { + expect(() => + buildModelReasoningConfigOption('qwen3.8-max', { + enabled: true, + effort: 0 as never, + }), + ).not.toThrow(); + }); + + it('keeps an opaque configured effort visible in workspace preview', () => { + expect( + buildModelReasoningConfigPreview('qwen3.8-max', { + enabled: true, + effort: 'vendor.ultra', + }), + ).toMatchObject([ + { + currentValue: 'vendor.ultra', + options: expect.arrayContaining([ + expect.objectContaining({ + value: 'vendor.ultra', + name: 'vendor.ultra', + }), + ]), + }, + ]); + }); + + it('shows a literal default effort without duplicating the ACP clear option', () => { + const option = buildModelReasoningConfigOption('qwen3.8-max', { + enabled: true, + effort: 'default', + }); + + expect(option?.currentValue).toBe('default'); + expect( + option?.options.filter( + (candidate) => 'value' in candidate && candidate.value === 'default', + ), + ).toHaveLength(1); + }); + + it('preserves model-specific effort values and display names', () => { + const reasoning = getModelConfiguration('qwen3.8-max')?.reasoning; + if (!reasoning || reasoning.toggleOnly) { + throw new Error('Expected tiered qwen3.8-max reasoning configuration'); + } + const mutable = reasoning as unknown as { + efforts: Array<{ value: string; name: string }>; + }; + mutable.efforts.push({ value: 'ultra', name: 'Ultra thinking' }); + try { + expect( + buildModelReasoningConfigOption('qwen3.8-max', { + enabled: true, + effort: 'ultra', + }), + ).toMatchObject({ + currentValue: 'ultra', + options: expect.arrayContaining([ + expect.objectContaining({ + value: 'ultra', + name: 'Ultra thinking', + }), + ]), + }); + } finally { + mutable.efforts.pop(); + } + }); + it('preserves mandatory thinking in the workspace preview', () => { expect( buildModelReasoningConfigPreview('qwen3.8-max', { @@ -93,7 +218,6 @@ describe('model configuration manifest', () => { }), ]); }); - it.each([ 'qwen3.5-plus', 'qwen3.6-plus', diff --git a/packages/cli/src/acp-integration/model-configuration.ts b/packages/cli/src/acp-integration/model-configuration.ts index 23dbb61ce6b..5b2a6427585 100644 --- a/packages/cli/src/acp-integration/model-configuration.ts +++ b/packages/cli/src/acp-integration/model-configuration.ts @@ -15,8 +15,11 @@ export type ModelReasoningConfiguration = | { readonly thinking: true; readonly toggleOnly?: false; - readonly efforts: readonly ReasoningEffort[]; - readonly defaultEffort: ReasoningEffort; + readonly efforts: ReadonlyArray<{ + readonly value: string; + readonly name: string; + }>; + readonly defaultEffort: string; }; const MODEL_CONFIGURATIONS: Readonly< @@ -40,7 +43,11 @@ const MODEL_CONFIGURATIONS: Readonly< 'qwen3.8-max': { reasoning: { thinking: true, - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ], defaultEffort: 'xhigh', }, }, @@ -53,13 +60,13 @@ export const REASONING_EFFORT_NAMES: Record = { low: 'Low', medium: 'Medium', high: 'High', - xhigh: 'Extra high', + xhigh: 'Extra High', max: 'Max', }; type ModelReasoningConfigState = { enabled?: boolean; - effort?: ReasoningEffort; + effort?: string; thinkingMandatory?: boolean; }; @@ -71,6 +78,19 @@ export function getModelConfiguration(modelId: string | undefined): return modelId ? MODEL_CONFIGURATIONS[modelId] : undefined; } +export function resolveReasoningPreviewState( + preference: false | string | undefined, + modelDefault: false | { effort?: string } | undefined, +): { enabled?: boolean; effort?: string } | undefined { + const reasoning = preference === undefined ? modelDefault : preference; + if (reasoning === false) return { enabled: false }; + const effort = typeof reasoning === 'string' ? reasoning : reasoning?.effort; + if (typeof effort !== 'string' || !effort.trim()) return undefined; + return effort === REASONING_EFFORT_NONE + ? { enabled: false } + : { enabled: true, effort }; +} + export function buildModelReasoningConfigOption( modelId: string | undefined, state: ModelReasoningConfigState = {}, @@ -79,13 +99,36 @@ export function buildModelReasoningConfigOption( if (!reasoning?.thinking) return undefined; const thinkingMandatory = state.thinkingMandatory === true; + const configuredEffort = + !reasoning.toggleOnly && + typeof state.effort === 'string' && + state.effort?.trim() && + state.effort !== REASONING_EFFORT_NONE && + state.effort !== REASONING_EFFORT_DEFAULT + ? state.effort + : undefined; + const projectedEffort = + configuredEffort === 'high' || configuredEffort === 'max' + ? 'xhigh' + : configuredEffort; + const matchedEffort = reasoning.toggleOnly + ? undefined + : reasoning.efforts.find((effort) => effort.value === projectedEffort); + const customEffort = + configuredEffort && !matchedEffort + ? { value: configuredEffort, name: configuredEffort } + : undefined; + const currentValue = state.enabled === false && !thinkingMandatory ? REASONING_EFFORT_NONE : reasoning.toggleOnly ? REASONING_EFFORT_DEFAULT - : (reasoning.efforts.find((effort) => effort === state.effort) ?? - reasoning.defaultEffort); + : state.effort === REASONING_EFFORT_DEFAULT + ? REASONING_EFFORT_DEFAULT + : (matchedEffort?.value ?? + customEffort?.value ?? + reasoning.defaultEffort); return { id: 'reasoning_effort', @@ -112,11 +155,21 @@ export function buildModelReasoningConfigOption( description: 'Use the model or provider thinking default', }, ] - : reasoning.efforts.map((effort) => ({ - value: effort, - name: REASONING_EFFORT_NAMES[effort], - description: 'Apply this effort to the next request', - }))), + : [ + { + value: REASONING_EFFORT_DEFAULT, + name: 'Default', + description: 'Use the model or provider thinking default', + }, + ...[ + ...reasoning.efforts, + ...(customEffort ? [customEffort] : []), + ].map((effort) => ({ + value: effort.value, + name: effort.name, + description: 'Apply this effort to the next request', + })), + ]), ], _meta: { 'qwenCode/reasoning': reasoning.toggleOnly diff --git a/packages/cli/src/acp-integration/session-model-persistence.test.ts b/packages/cli/src/acp-integration/session-model-persistence.test.ts index 5da57d60276..06f67da0d48 100644 --- a/packages/cli/src/acp-integration/session-model-persistence.test.ts +++ b/packages/cli/src/acp-integration/session-model-persistence.test.ts @@ -10,8 +10,9 @@ import type { Config, SessionRestoreProjection, } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../config/settings.js'; import { - applyRestoredSessionModel, + applyRestoredSessionModel as applyRestoredSessionModelWithSettings, recordDaemonSessionModel, recordDaemonSessionModelFromConfig, restoreSessionModelThenAuthenticate, @@ -42,6 +43,16 @@ function resolvedWhen(baseUrl: string) { ); } +const emptySettings = { merged: {} } as LoadedSettings; + +function applyRestoredSessionModel( + config: Config, + projection: SessionRestoreProjection | undefined, + settings = emptySettings, +): Promise { + return applyRestoredSessionModelWithSettings(config, projection, settings); +} + describe('session-model-persistence', () => { it('records through ChatRecordingService', async () => { const recordSessionModel = vi.fn().mockResolvedValue(true); @@ -150,6 +161,44 @@ describe('session-model-persistence', () => { ); }); + it('clears an incompatible live effort after restoring a toggle-only model', async () => { + let routeIdentity = 'qwen3.8-max@route'; + const setReasoningEffort = vi.fn(); + const config = { + getModel: vi.fn().mockReturnValue('qwen3.8-max'), + getAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI), + getCurrentModelRegistryBaseUrl: vi.fn().mockReturnValue(undefined), + getModelRouteIdentity: vi.fn(() => routeIdentity), + getReasoningPreference: vi.fn().mockReturnValue('ultra'), + setReasoningEffort, + disableReasoning: vi.fn(), + switchModel: vi.fn().mockImplementation(async () => { + routeIdentity = 'qwen3.7-max@route'; + }), + } as unknown as Config; + const setValue = vi.fn(); + const settings = { + merged: { model: { reasoningEffort: 'ultra' } }, + setValue, + } as unknown as LoadedSettings; + + await applyRestoredSessionModel( + config, + recordingProjection({ + lastCompletedUuid: 'leaf', + turnParentUuids: [null], + sessionModel: { + modelId: 'qwen3.7-max', + authType: AuthType.USE_OPENAI, + }, + }), + settings, + ); + + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); + expect(setValue).not.toHaveBeenCalled(); + }); + it('falls back to the last assistant model when no session_model exists', async () => { const switchModel = vi.fn().mockResolvedValue(undefined); const config = { @@ -532,6 +581,7 @@ describe('session-model-persistence', () => { }, }), authenticate, + emptySettings, ); expect(authenticate).toHaveBeenCalledTimes(2); @@ -549,6 +599,55 @@ describe('session-model-persistence', () => { ); }); + it('restores the live effort when authentication rolls back the restored model', async () => { + let currentAuth: string | undefined = AuthType.USE_OPENAI; + let currentModel = 'qwen3.8-max'; + let reasoningPreference: false | string | undefined = 'ultra'; + const setReasoningEffort = vi.fn((effort: string | undefined) => { + reasoningPreference = effort; + }); + const switchModel = vi.fn( + async (authType: string, modelId: string): Promise => { + currentAuth = authType; + currentModel = modelId; + }, + ); + const authenticate = vi + .fn() + .mockRejectedValueOnce(new Error('oauth expired')) + .mockResolvedValueOnce(undefined); + const config = { + getModel: vi.fn(() => currentModel), + getAuthType: vi.fn(() => currentAuth), + getCurrentModelRegistryBaseUrl: vi.fn().mockReturnValue(undefined), + getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined), + getReasoningPreference: vi.fn(() => reasoningPreference), + setReasoningEffort, + disableReasoning: vi.fn(() => { + reasoningPreference = false; + }), + switchModel, + } as unknown as Config; + + await restoreSessionModelThenAuthenticate( + config, + recordingProjection({ + lastCompletedUuid: 'leaf', + turnParentUuids: [null], + sessionModel: { + modelId: 'qwen3.7-max', + authType: AuthType.QWEN_OAUTH, + }, + }), + authenticate, + { merged: { model: { reasoningEffort: 'ultra' } } } as LoadedSettings, + ); + + expect(setReasoningEffort).toHaveBeenNthCalledWith(1, undefined); + expect(setReasoningEffort).toHaveBeenNthCalledWith(2, 'ultra'); + expect(reasoningPreference).toBe('ultra'); + }); + it('does not retry authentication when restore did not change the live model', async () => { const switchModel = vi.fn().mockResolvedValue(undefined); const authenticate = vi.fn().mockRejectedValue(new Error('auth required')); @@ -570,6 +669,7 @@ describe('session-model-persistence', () => { lastAssistantModel: 'qwen3-coder-plus', }), authenticate, + emptySettings, ), ).rejects.toThrow('auth required'); @@ -618,6 +718,7 @@ describe('session-model-persistence', () => { }, }), authenticate, + emptySettings, ); expect(authenticate).toHaveBeenCalledTimes(2); @@ -674,6 +775,7 @@ describe('session-model-persistence', () => { }, }), authenticate, + emptySettings, ); expect(authenticate).toHaveBeenCalledTimes(2); @@ -737,6 +839,7 @@ describe('session-model-persistence', () => { }, }), authenticate, + emptySettings, ); expect(authenticate).toHaveBeenCalledTimes(2); @@ -788,6 +891,7 @@ describe('session-model-persistence', () => { }, }), authenticate, + emptySettings, ), ).rejects.toThrow('settings auth failed'); @@ -834,6 +938,7 @@ describe('session-model-persistence', () => { }, }), authenticate, + emptySettings, ), ).rejects.toThrow('oauth expired'); diff --git a/packages/cli/src/acp-integration/session-model-persistence.ts b/packages/cli/src/acp-integration/session-model-persistence.ts index 9c23ffaa502..d52c6d39c07 100644 --- a/packages/cli/src/acp-integration/session-model-persistence.ts +++ b/packages/cli/src/acp-integration/session-model-persistence.ts @@ -13,6 +13,8 @@ import { type SessionModelRecordPayload, type SessionRestoreProjection, } from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { clearIncompatibleReasoningEffortForModel } from '../config/reasoning-effort-persistence.js'; const debugLogger = createDebugLogger('SESSION_MODEL'); const AUTH_TYPES = new Set(Object.values(AuthType)); @@ -122,6 +124,7 @@ function recordedRouteMatches( export async function applyRestoredSessionModel( config: Config, projection: SessionRestoreProjection | undefined, + settings: LoadedSettings, ): Promise { const recorded = projection?.runtime.recording.sessionModel; const fallbackModel = projection?.runtime.recording.lastAssistantModel; @@ -187,7 +190,15 @@ export async function applyRestoredSessionModel( } : undefined; try { + const previousModelRouteIdentity = config.getModelRouteIdentity?.(); await config.switchModel(authType, switchModelId, switchOptions); + clearIncompatibleReasoningEffortForModel( + config, + settings, + targetModel, + false, + previousModelRouteIdentity, + ); } catch (error) { debugLogger.warn( `restore session model failed (${targetModel}/${authType}): ${ @@ -201,12 +212,14 @@ export async function restoreSessionModelThenAuthenticate( config: Config, projection: SessionRestoreProjection | undefined, authenticate: () => Promise, + settings: LoadedSettings, ): Promise { const originalAuth = liveAuthType(config); const originalModel = config.getModel(); const originalBaseUrl = config.getCurrentModelRegistryBaseUrl?.(); const originalRuntimeSnapshot = config.getActiveRuntimeModelSnapshot?.(); - await applyRestoredSessionModel(config, projection); + const originalReasoningPreference = config.getReasoningPreference?.(); + await applyRestoredSessionModel(config, projection, settings); try { await authenticate(); } catch (error) { @@ -246,6 +259,11 @@ export async function restoreSessionModelThenAuthenticate( } catch { throw error; } + if (originalReasoningPreference === false) { + config.disableReasoning(); + } else if (typeof originalReasoningPreference === 'string') { + config.setReasoningEffort(originalReasoningPreference); + } await authenticate(); } } diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index afd28a48bcd..61354a77178 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -41,7 +41,7 @@ import { SYSTEM_REMINDER_CLOSE, } from '@qwen-code/qwen-code-core'; import * as core from '@qwen-code/qwen-code-core'; -import { SettingScope } from '../../config/settings.js'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; import type { AgentSideConnection, PermissionOption, @@ -51,11 +51,11 @@ import type { SessionUpdate, } from '@agentclientprotocol/sdk'; import { RequestError } from '@agentclientprotocol/sdk'; -import type { LoadedSettings } from '../../config/settings.js'; import * as nonInteractiveCliCommands from '../../nonInteractiveCliCommands.js'; import { CommandKind } from '../../ui/commands/types.js'; import { buildAcpModelOptions } from '../../utils/acpModelUtils.js'; import { CHANNEL_PROMPT_META_KEY } from '@qwen-code/channel-base'; +import { REASONING_EFFORT_PERSISTENCE_ERROR_KIND } from '@qwen-code/sdk/daemon'; import { CAPTURE_SCREEN_CONTEXT_TOOL_NAME } from '../live/capture-screen-context.js'; import { SPEAK_TO_USER_TOOL_NAME } from '../live/live-speak-to-user.js'; import { @@ -806,7 +806,12 @@ describe('Session', () => { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getApprovalModeRevision: vi.fn().mockReturnValue(0), switchModel: switchModelSpy, + setReasoningEffort: vi.fn(), + getReasoningPreference: vi.fn().mockReturnValue(undefined), getModel: vi.fn().mockImplementation(() => currentModel), + getModelRouteIdentity: vi + .fn() + .mockImplementation(() => `${currentAuthType}:${currentModel}`), getSessionId: vi.fn().mockReturnValue('test-session-id'), getSessionSourceType: vi.fn().mockReturnValue(undefined), isProvisionalWorkspace: vi.fn().mockReturnValue(false), @@ -907,10 +912,13 @@ describe('Session', () => { mockSettings = { merged: {}, isTrusted: false, - user: { settings: {} }, - workspace: { settings: {} }, + system: { settings: {}, originalSettings: {} }, + systemDefaults: { settings: {}, originalSettings: {} }, + user: { settings: {}, originalSettings: {} }, + workspace: { settings: {}, originalSettings: {} }, setValue: vi.fn(), reloadScopeFromDisk: vi.fn(), + recomputeMerged: vi.fn(), } as unknown as LoadedSettings; getAvailableCommandsSpy = vi.mocked(nonInteractiveCliCommands) @@ -4580,6 +4588,86 @@ describe('Session', () => { }); }); + it('clears persisted effort when switching the default to a toggle-only Qwen model', async () => { + mockSettings.user.settings = { + model: { reasoningEffort: 'ultra' }, + }; + vi.mocked(mockConfig.getReasoningPreference).mockReturnValue('ultra'); + + await session.setModel({ + sessionId: 'test-session-id', + modelId: `qwen3.7-max(${AuthType.USE_OPENAI})`, + }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.reasoningEffort', + undefined, + undefined, + { throwOnWriteFailure: true }, + ); + expect(mockConfig.setReasoningEffort).toHaveBeenCalledWith(undefined); + }); + + it('persists the model selection when effort cleanup cannot be saved', async () => { + mockSettings.user.settings = { + model: { reasoningEffort: 'ultra' }, + }; + vi.mocked(mockConfig.getReasoningPreference).mockReturnValue('ultra'); + vi.mocked(mockSettings.setValue).mockImplementation((_scope, key) => { + if (key === 'model.reasoningEffort') { + throw new Error('EACCES'); + } + }); + + await expect( + session.setModel({ + sessionId: 'test-session-id', + modelId: `qwen3.7-max(${AuthType.USE_OPENAI})`, + }), + ).resolves.toBeDefined(); + + expect(mockConfig.setReasoningEffort).toHaveBeenCalledWith(undefined); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.name', + 'qwen3.7-max', + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.baseUrl', + '', + ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + }); + + it('clears persisted opaque effort when switching the default to a non-Qwen model', async () => { + mockSettings.user.settings = { + model: { reasoningEffort: 'vendor.ultra' }, + }; + vi.mocked(mockConfig.getReasoningPreference).mockReturnValue( + 'vendor.ultra', + ); + + await session.setModel({ + sessionId: 'test-session-id', + modelId: `gemini-2.5-pro(${AuthType.USE_GEMINI})`, + }); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.reasoningEffort', + undefined, + undefined, + { throwOnWriteFailure: true }, + ); + expect(mockConfig.setReasoningEffort).toHaveBeenCalledWith(undefined); + }); + it('emits a current_model_update extNotification after switching (A1)', async () => { await session.setModel({ sessionId: 'test-session-id', @@ -4766,6 +4854,10 @@ describe('Session', () => { }); it('can switch the session model without persisting a new default', async () => { + mockSettings.user.settings = { + model: { reasoningEffort: 'ultra' }, + }; + vi.mocked(mockConfig.getReasoningPreference).mockReturnValue('ultra'); await session.setModel( { sessionId: 'test-session-id', @@ -4780,6 +4872,7 @@ describe('Session', () => { undefined, ); expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(mockConfig.setReasoningEffort).toHaveBeenCalledWith(undefined); expect(mockChatRecordingService.recordSessionModel).toHaveBeenCalledWith({ modelId: 'qwen3-coder-flash', authType: AuthType.USE_OPENAI, @@ -4787,6 +4880,10 @@ describe('Session', () => { }); it('does not persist a shared model default for a standalone session', async () => { + mockSettings.user.settings = { + model: { reasoningEffort: 'ultra' }, + }; + vi.mocked(mockConfig.getReasoningPreference).mockReturnValue('ultra'); session.dispose(); vi.mocked(mockConfig.getSessionSourceType).mockReturnValue('standalone'); session = new Session( @@ -4810,6 +4907,7 @@ describe('Session', () => { undefined, ); expect(mockSettings.setValue).not.toHaveBeenCalled(); + expect(mockConfig.setReasoningEffort).toHaveBeenCalledWith(undefined); expect(mockChatRecordingService.recordSessionModel).toHaveBeenCalledWith({ modelId: 'qwen3-coder-flash', authType: AuthType.USE_OPENAI, @@ -4830,6 +4928,406 @@ describe('Session', () => { }); }); + describe('persistReasoningEffort', () => { + it('rejects persistence for a standalone session', () => { + session.dispose(); + vi.mocked(mockConfig.getSessionSourceType).mockReturnValue('standalone'); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + ); + + let thrown: unknown; + try { + session.persistReasoningEffort('high'); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + code: -32602, + message: expect.stringContaining( + 'Reasoning effort cannot be persisted for this session', + ), + }); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + }); + + it('writes opaque effort values to the model selection scope', () => { + session.persistReasoningEffort('ultra'); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.reasoningEffort', + 'ultra', + undefined, + { throwOnWriteFailure: true }, + ); + }); + + it('deletes blank effort values instead of persisting them', () => { + mockSettings.user.settings = { + model: { reasoningEffort: 'medium' }, + }; + mockSettings.user.originalSettings = structuredClone( + mockSettings.user.settings, + ); + + session.persistReasoningEffort(' '); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.reasoningEffort', + undefined, + undefined, + { throwOnWriteFailure: true }, + ); + }); + + it('does not create a settings file when clearing an absent effort', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-reasoning-settings-'), + ); + const userPath = path.join(tempDir, 'missing-user.json'); + const loadedSettings = new LoadedSettings( + { + path: path.join(tempDir, 'system.json'), + settings: {}, + originalSettings: {}, + rawJson: '{}', + }, + { + path: path.join(tempDir, 'system-defaults.json'), + settings: {}, + originalSettings: {}, + rawJson: '{}', + }, + { + path: userPath, + settings: {}, + originalSettings: {}, + }, + { + path: path.join(tempDir, 'workspace.json'), + settings: {}, + originalSettings: {}, + rawJson: '{}', + }, + false, + new Set(), + ); + const unloadedSession = new Session( + 'absent-reasoning-session', + mockConfig, + mockClient, + loadedSettings, + ); + + try { + expect(unloadedSession.persistReasoningEffort(undefined)).toEqual({ + scope: SettingScope.User, + effectiveValue: undefined, + }); + expect(loadedSettings.user.settings).toEqual({}); + await expect(fs.stat(userPath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('uses the workspace model scope when it owns modelProviders', () => { + (mockSettings as unknown as { isTrusted: boolean }).isTrusted = true; + mockSettings.workspace.settings = { modelProviders: {} }; + + session.persistReasoningEffort('none'); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'model.reasoningEffort', + 'none', + undefined, + { throwOnWriteFailure: true }, + ); + }); + + it.each([ + { + name: 'trusted workspace over a user target', + configure: () => { + (mockSettings as unknown as { isTrusted: boolean }).isTrusted = true; + mockSettings.workspace.settings = { + model: { reasoningEffort: 'low' }, + }; + }, + }, + { + name: 'system settings over a workspace target', + configure: () => { + (mockSettings as unknown as { isTrusted: boolean }).isTrusted = true; + mockSettings.workspace.settings = { modelProviders: {} }; + mockSettings.system.settings = { + model: { reasoningEffort: 'low' }, + }; + }, + }, + ])('rejects $name before writing the target scope', ({ configure }) => { + configure(); + (mockSettings as unknown as { merged: Record }).merged = + { model: { reasoningEffort: 'low' } }; + + let thrown: unknown; + try { + session.persistReasoningEffort('high'); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + code: -32603, + data: { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + message: expect.stringContaining( + 'shadowed by a higher-precedence setting', + ), + }); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + }); + + it('returns the inherited effective value after clearing a workspace override', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-reasoning-settings-'), + ); + const userPath = path.join(tempDir, 'user.json'); + const workspacePath = path.join(tempDir, 'workspace.json'); + const userSettings = { + model: { reasoningEffort: 'medium' }, + }; + const workspaceSettings = { + modelProviders: {}, + model: { reasoningEffort: 'none' }, + }; + await fs.writeFile(userPath, JSON.stringify(userSettings)); + await fs.writeFile(workspacePath, JSON.stringify(workspaceSettings)); + const layeredSettings = new LoadedSettings( + { + path: path.join(tempDir, 'system.json'), + settings: {}, + originalSettings: {}, + rawJson: '{}', + }, + { + path: path.join(tempDir, 'system-defaults.json'), + settings: {}, + originalSettings: {}, + rawJson: '{}', + }, + { + path: userPath, + settings: structuredClone(userSettings), + originalSettings: structuredClone(userSettings), + rawJson: JSON.stringify(userSettings), + }, + { + path: workspacePath, + settings: structuredClone(workspaceSettings), + originalSettings: structuredClone(workspaceSettings), + rawJson: JSON.stringify(workspaceSettings), + }, + true, + new Set(), + ); + const layeredSession = new Session( + 'layered-reasoning-session', + mockConfig, + mockClient, + layeredSettings, + ); + + try { + expect(layeredSession.persistReasoningEffort(undefined)).toEqual({ + scope: SettingScope.Workspace, + effectiveValue: 'medium', + }); + expect(layeredSettings.merged.model?.reasoningEffort).toBe('medium'); + expect( + Object.hasOwn( + layeredSettings.workspace.settings.model ?? {}, + 'reasoningEffort', + ), + ).toBe(false); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('rejects clearing an effort owned by a different active scope', () => { + (mockSettings as unknown as { isTrusted: boolean }).isTrusted = true; + mockSettings.workspace.settings = { modelProviders: {} }; + mockSettings.user.settings = { + model: { reasoningEffort: 'high' }, + }; + (mockSettings as unknown as { merged: Record }).merged = + { model: { reasoningEffort: 'high' } }; + + let thrown: unknown; + try { + session.persistReasoningEffort(undefined); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + code: -32603, + data: { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + message: expect.stringContaining( + 'default could not be cleared: it is persisted in a different settings scope', + ), + }); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + }); + + it('rejects clearing an effort owned by system defaults', () => { + mockSettings.systemDefaults.settings = { + model: { reasoningEffort: 'high' }, + }; + (mockSettings as unknown as { merged: Record }).merged = + { model: { reasoningEffort: 'high' } }; + + let thrown: unknown; + try { + session.persistReasoningEffort(undefined); + } catch (error) { + thrown = error; + } + + expect(thrown).toMatchObject({ + code: -32603, + data: { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + message: expect.stringContaining( + 'default could not be cleared: it is persisted in a different settings scope', + ), + }); + expect(mockSettings.setValue).not.toHaveBeenCalled(); + }); + + it('writes through the LoadedSettings owned by each Session', () => { + const otherSetValue = vi.fn(); + const otherSettings = { + ...mockSettings, + setValue: otherSetValue, + } as unknown as LoadedSettings; + const otherSession = new Session( + 'other-session-id', + mockConfig, + mockClient, + otherSettings, + ); + + session.persistReasoningEffort('medium'); + otherSession.persistReasoningEffort('Vendor.Custom-EFFORT'); + + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.reasoningEffort', + 'medium', + undefined, + { throwOnWriteFailure: true }, + ); + expect(otherSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.reasoningEffort', + 'Vendor.Custom-EFFORT', + undefined, + { throwOnWriteFailure: true }, + ); + }); + + it('reports that live reasoning remains active after a write failure', () => { + vi.mocked(mockSettings.setValue).mockImplementationOnce(() => { + throw new Error('disk full'); + }); + + expect(() => session.persistReasoningEffort('medium')).toThrow( + 'Reasoning effort is active for the current session, but the default could not be saved: disk full', + ); + }); + }); + + it('forwards persisted slash-command setting changes to the bridge', async () => { + session.dispose(); + session = new Session( + 'test-session-id', + mockConfig, + mockClient, + mockSettings, + undefined, + undefined, + () => [ + { + id: 'reasoning_effort', + name: 'Reasoning effort', + type: 'select', + currentValue: 'high', + options: [{ value: 'high', name: 'High' }], + }, + ], + ); + vi.mocked( + nonInteractiveCliCommands.handleSlashCommand, + ).mockImplementationOnce( + async (_query, _abort, _config, _settings, hooks) => { + hooks?.notifySettingsChanged?.({ + key: 'model.reasoningEffort', + value: 'high', + scope: 'user', + }); + return { + type: 'message', + messageType: 'info', + content: 'Reasoning effort: high', + resolvedCommand: { + name: 'effort', + kind: CommandKind.BUILT_IN, + }, + }; + }, + ); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: '/effort high' }], + }); + + expect(mockClient.extNotification).toHaveBeenCalledWith( + 'qwen/notify/session/settings-changed', + { + v: 1, + sessionId: 'test-session-id', + key: 'model.reasoningEffort', + value: 'high', + scope: 'user', + }, + ); + expect(mockClient.sessionUpdate).toHaveBeenCalledWith({ + sessionId: 'test-session-id', + update: { + sessionUpdate: 'config_option_update', + configOptions: [ + { + id: 'reasoning_effort', + name: 'Reasoning effort', + type: 'select', + currentValue: 'high', + options: [{ value: 'high', name: 'High' }], + }, + ], + }, + }); + }); + describe('sendAvailableCommandsUpdate', () => { it('sends available_commands_update from getAvailableCommands()', async () => { getAvailableCommandsSpy.mockResolvedValueOnce([ diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2e19ef3935a..bec65ae3927 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -259,6 +259,7 @@ import { } from '../extension-skills.js'; import { RequestError } from '@agentclientprotocol/sdk'; +import { REASONING_EFFORT_PERSISTENCE_ERROR_KIND } from '@qwen-code/sdk/daemon'; import type { AvailableCommand, ContentBlock, @@ -268,6 +269,7 @@ import type { RequestPermissionRequest, RequestPermissionResponse, SessionNotification, + SessionConfigOption, SessionUpdate, SetSessionModeRequest, SetSessionModeResponse, @@ -276,6 +278,11 @@ import type { AgentSideConnection, } from '@agentclientprotocol/sdk'; import { SettingScope, type LoadedSettings } from '../../config/settings.js'; +import { clearIncompatibleReasoningEffortForModel } from '../../config/reasoning-effort-persistence.js'; +import { + deleteNestedPropertySafe, + settingExistsInScope, +} from '../../config/settingsUtils.js'; import { insertAfterFunctionResponses } from '../../nonInteractive/nonInteractiveHelpers.js'; import { isSameConversationPath } from '../../utils/conversation-directory-identity.js'; import { normalizePartList } from '../../utils/normalize-part-list.js'; @@ -2073,6 +2080,9 @@ export class Session implements SessionContext { * a full snapshot; the Session itself keeps no reporting state. */ private readonly onActiveWorkChanged?: () => void, + private readonly buildConfigOptions?: ( + config: Config, + ) => SessionConfigOption[], ) { this.sessionId = id; this.requiresManagedConversationBinding = @@ -5052,6 +5062,36 @@ export class Session implements SessionContext { // long-lived Session; without this the goal-state // subscription stays on the disposed instance. startNewSession: () => this.rebindGoalRuntimeForNewSession(), + notifySettingsChanged: ({ key, value, scope }) => { + const configOptions = this.buildConfigOptions?.( + this.config, + ); + if (configOptions) { + void this.sendUpdate({ + sessionUpdate: 'config_option_update', + configOptions, + }).catch((error) => { + debugLogger.debug( + 'config_option_update notification failed', + error, + ); + }); + } + void this.client + .extNotification('qwen/notify/session/settings-changed', { + v: 1, + sessionId: this.sessionId, + key, + value, + scope, + }) + .catch((error) => { + debugLogger.debug( + 'settings-changed extNotification failed', + error, + ); + }); + }, }, this.slashCommandPolicy, ); @@ -9492,6 +9532,115 @@ export class Session implements SessionContext { }); } + persistReasoningEffort(value: string | undefined): { + scope: SettingScope.User | SettingScope.Workspace; + effectiveValue: string | undefined; + } { + if (this.requiresManagedConversationBinding) { + throw RequestError.invalidParams( + undefined, + 'Reasoning effort cannot be persisted for this session', + ); + } + const persistedValue = value?.trim() ? value : undefined; + const persistScope = + getPersistScopeForModelSelection(this.settings) === SettingScope.Workspace + ? SettingScope.Workspace + : SettingScope.User; + const settingsFile = + persistScope === SettingScope.Workspace + ? this.settings.workspace + : this.settings.user; + const targetOwnsPersistedValue = settingExistsInScope( + 'model.reasoningEffort', + settingsFile.settings, + ); + const otherActiveScopeOwnsPersistedValue = + settingExistsInScope( + 'model.reasoningEffort', + this.settings.system.settings, + ) || + settingExistsInScope( + 'model.reasoningEffort', + this.settings.systemDefaults.settings, + ) || + (persistScope === SettingScope.Workspace + ? settingExistsInScope( + 'model.reasoningEffort', + this.settings.user.settings, + ) + : this.settings.isTrusted && + settingExistsInScope( + 'model.reasoningEffort', + this.settings.workspace.settings, + )); + if ( + persistedValue === undefined && + !targetOwnsPersistedValue && + otherActiveScopeOwnsPersistedValue + ) { + throw RequestError.internalError( + { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + 'Reasoning effort is active for the current session, but the default could not be cleared: it is persisted in a different settings scope', + ); + } + if ( + persistedValue !== undefined && + this.settings.merged.model?.reasoningEffort !== persistedValue && + (settingExistsInScope( + 'model.reasoningEffort', + this.settings.system.settings, + ) || + (persistScope === SettingScope.User && + this.settings.isTrusted && + settingExistsInScope( + 'model.reasoningEffort', + this.settings.workspace.settings, + ))) + ) { + throw RequestError.internalError( + { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + 'Reasoning effort is active for the current session, but the default could not be saved: the selected value is shadowed by a higher-precedence setting', + ); + } + try { + if (persistedValue !== undefined || targetOwnsPersistedValue) { + this.settings.setValue( + persistScope, + 'model.reasoningEffort', + persistedValue, + undefined, + { throwOnWriteFailure: true }, + ); + } + if (persistedValue === undefined) { + deleteNestedPropertySafe( + settingsFile.settings as Record, + 'model.reasoningEffort', + ); + deleteNestedPropertySafe( + settingsFile.originalSettings as Record, + 'model.reasoningEffort', + ); + this.settings.recomputeMerged(); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw RequestError.internalError( + { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + `Reasoning effort is active for the current session, but the default could not be saved: ${message}`, + ); + } + const effectiveValue = this.settings.merged.model?.reasoningEffort; + return { + scope: persistScope, + effectiveValue: + typeof effectiveValue === 'string' && effectiveValue.trim() + ? effectiveValue + : undefined, + }; + } + /** * Sets the model for the current session. * Validates the model ID and switches the model via Config. @@ -9541,6 +9690,7 @@ export class Session implements SessionContext { : {}), } : undefined; + const previousModelRouteIdentity = this.config.getModelRouteIdentity(); await this.config.switchModel( selectedAuthType, parsed.modelId, @@ -9599,6 +9749,13 @@ export class Session implements SessionContext { const persistDefault = !this.requiresManagedConversationBinding && (options.persistDefault ?? true); + clearIncompatibleReasoningEffortForModel( + this.config, + this.settings, + effectiveModelId, + persistDefault, + previousModelRouteIdentity, + ); if (persistDefault) { const persistScope = getPersistScopeForModelSelection(this.settings); this.settings.setValue( diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 774612f2f31..f1ca044d1fd 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -2341,6 +2341,8 @@ export async function loadCliConfig( providerProtocolConfig, generationConfigSources: resolvedCliConfig.sources, generationConfig: resolvedCliConfig.generationConfig, + reasoningPreference: resolvedCliConfig.reasoningPreference, + reasoningDefault: resolvedCliConfig.reasoningDefault, initialModelRegistryBaseUrl: resolvedCliConfig.registryBaseUrl, warnings: resolvedCliConfig.warnings, bareMode, diff --git a/packages/cli/src/config/reasoning-effort-persistence.test.ts b/packages/cli/src/config/reasoning-effort-persistence.test.ts new file mode 100644 index 00000000000..c6c8b45553e --- /dev/null +++ b/packages/cli/src/config/reasoning-effort-persistence.test.ts @@ -0,0 +1,398 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { LoadedSettings, SettingScope } from './settings.js'; +import { clearIncompatibleReasoningEffortForModel } from './reasoning-effort-persistence.js'; + +function makeSettings({ + isTrusted = true, + system = {}, + systemDefaults = {}, + user = {}, + workspace = {}, +}: { + isTrusted?: boolean; + system?: Record; + systemDefaults?: Record; + user?: Record; + workspace?: Record; +} = {}): { settings: LoadedSettings; setValue: ReturnType } { + const files = { + system: { settings: system, originalSettings: structuredClone(system) }, + systemDefaults: { + settings: systemDefaults, + originalSettings: structuredClone(systemDefaults), + }, + user: { settings: user, originalSettings: structuredClone(user) }, + workspace: { + settings: workspace, + originalSettings: structuredClone(workspace), + }, + }; + const setValue = vi.fn((scope: SettingScope, key: string, value: unknown) => { + if (key !== 'model.reasoningEffort') return; + const target = scope === SettingScope.Workspace ? workspace : user; + const model = (target['model'] ??= {}) as Record; + model['reasoningEffort'] = value; + }); + return { + settings: { + isTrusted, + ...files, + get merged() { + let ownsEffort = false; + let effort: unknown; + for (const source of [ + systemDefaults, + user, + ...(isTrusted ? [workspace] : []), + system, + ]) { + const model = source['model'] as Record | undefined; + if ( + model && + Object.prototype.hasOwnProperty.call(model, 'reasoningEffort') + ) { + ownsEffort = true; + effort = model['reasoningEffort']; + } + } + return ownsEffort ? { model: { reasoningEffort: effort } } : {}; + }, + setValue, + recomputeMerged: vi.fn(), + } as unknown as LoadedSettings, + setValue, + }; +} + +function makeConfig( + reasoningPreference?: false | string, + modelRouteIdentity = 'current-model@route', +): { + config: Config; + disableReasoning: ReturnType; + setReasoningEffort: ReturnType; +} { + const disableReasoning = vi.fn(); + const setReasoningEffort = vi.fn(); + return { + config: { + disableReasoning, + getReasoningPreference: vi.fn().mockReturnValue(reasoningPreference), + getModelRouteIdentity: vi.fn().mockReturnValue(modelRouteIdentity), + setReasoningEffort, + } as unknown as Config, + disableReasoning, + setReasoningEffort, + }; +} + +describe('clearIncompatibleReasoningEffortForModel', () => { + it('clears persisted effort from every active writable scope', () => { + const { settings, setValue } = makeSettings({ + user: { model: { reasoningEffort: 'ultra' } }, + workspace: { model: { reasoningEffort: 'high' } }, + }); + const { config, setReasoningEffort } = makeConfig('ultra'); + + expect( + clearIncompatibleReasoningEffortForModel(config, settings, 'qwen3.7-max'), + ).toBe(true); + expect(setValue).toHaveBeenNthCalledWith( + 1, + SettingScope.Workspace, + 'model.reasoningEffort', + undefined, + undefined, + { throwOnWriteFailure: true }, + ); + expect(setValue).toHaveBeenNthCalledWith( + 2, + SettingScope.User, + 'model.reasoningEffort', + undefined, + undefined, + { throwOnWriteFailure: true }, + ); + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); + }); + + it('does not clear effort for tiered Qwen models', () => { + const { settings, setValue } = makeSettings({ + user: { model: { reasoningEffort: 'ultra' } }, + }); + const { config, setReasoningEffort } = makeConfig('ultra'); + + expect( + clearIncompatibleReasoningEffortForModel( + config, + settings, + 'qwen3.8-max-latest', + ), + ).toBe(false); + expect(setValue).not.toHaveBeenCalled(); + expect(setReasoningEffort).not.toHaveBeenCalled(); + }); + + it('clears opaque effort for non-Qwen models', () => { + const { settings, setValue } = makeSettings({ + user: { model: { reasoningEffort: 'ultra' } }, + }); + const { config, setReasoningEffort } = makeConfig( + 'ultra', + 'glm-5.2@target-route', + ); + + expect( + clearIncompatibleReasoningEffortForModel( + config, + settings, + 'glm-5.2', + true, + 'previous-model@route', + ), + ).toBe(true); + expect(setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.reasoningEffort', + undefined, + undefined, + { throwOnWriteFailure: true }, + ); + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); + }); + + it('preserves built-in effort for non-Qwen models', () => { + const { settings, setValue } = makeSettings({ + user: { model: { reasoningEffort: 'high' } }, + }); + const { config, setReasoningEffort } = makeConfig('high'); + + expect( + clearIncompatibleReasoningEffortForModel(config, settings, 'glm-5.2'), + ).toBe(false); + expect(setValue).not.toHaveBeenCalled(); + expect(setReasoningEffort).not.toHaveBeenCalled(); + }); + + it('preserves a persisted disabled preference for toggle-only Qwen models', () => { + const { settings, setValue } = makeSettings({ + user: { model: { reasoningEffort: 'none' } }, + }); + const { config, setReasoningEffort } = makeConfig(false); + + expect( + clearIncompatibleReasoningEffortForModel(config, settings, 'coder-model'), + ).toBe(false); + expect(setValue).not.toHaveBeenCalled(); + expect(setReasoningEffort).not.toHaveBeenCalled(); + }); + + it('preserves a portable persisted tier while clearing an opaque live override', () => { + const { settings, setValue } = makeSettings({ + user: { model: { reasoningEffort: 'high' } }, + }); + const { config, setReasoningEffort } = makeConfig('Vendor.Ultra'); + + expect( + clearIncompatibleReasoningEffortForModel(config, settings, 'glm-5.2'), + ).toBe(true); + expect(setValue).not.toHaveBeenCalled(); + expect(setReasoningEffort).toHaveBeenCalledWith('high'); + }); + + it('reveals a lower-scope tier after clearing a higher-scope opaque effort', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-reasoning-cleanup-'), + ); + const userPath = path.join(tempDir, 'user.json'); + const workspacePath = path.join(tempDir, 'workspace.json'); + const user = { model: { reasoningEffort: 'high' } }; + const workspace = { model: { reasoningEffort: 'Vendor.Ultra' } }; + await fs.writeFile(userPath, JSON.stringify(user)); + await fs.writeFile(workspacePath, JSON.stringify(workspace)); + const settings = new LoadedSettings( + { + path: path.join(tempDir, 'system.json'), + settings: {}, + originalSettings: {}, + }, + { + path: path.join(tempDir, 'system-defaults.json'), + settings: {}, + originalSettings: {}, + }, + { + path: userPath, + settings: structuredClone(user), + originalSettings: structuredClone(user), + }, + { + path: workspacePath, + settings: structuredClone(workspace), + originalSettings: structuredClone(workspace), + }, + true, + new Set(), + ); + const { config, setReasoningEffort } = makeConfig( + 'Vendor.Ultra', + 'glm-5.2@target-route', + ); + + try { + expect( + clearIncompatibleReasoningEffortForModel( + config, + settings, + 'glm-5.2', + true, + 'previous-model@route', + ), + ).toBe(true); + expect(settings.merged.model?.reasoningEffort).toBe('high'); + expect( + Object.prototype.hasOwnProperty.call( + settings.workspace.settings.model ?? {}, + 'reasoningEffort', + ), + ).toBe(false); + expect( + Object.prototype.hasOwnProperty.call( + settings.workspace.originalSettings.model ?? {}, + 'reasoningEffort', + ), + ).toBe(false); + expect(setReasoningEffort).toHaveBeenCalledWith('high'); + expect(JSON.parse(await fs.readFile(workspacePath, 'utf8'))).toEqual({ + model: {}, + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('reveals a lower-scope disabled preference after clearing a higher-scope tier', () => { + const { settings } = makeSettings({ + user: { model: { reasoningEffort: 'none' } }, + workspace: { model: { reasoningEffort: 'high' } }, + }); + const { config, disableReasoning } = makeConfig( + 'high', + 'qwen3.7-max@target-route', + ); + + expect( + clearIncompatibleReasoningEffortForModel( + config, + settings, + 'qwen3.7-max', + true, + 'qwen3.8-max@previous-route', + ), + ).toBe(true); + expect(disableReasoning).toHaveBeenCalledOnce(); + }); + + it('still clears the live preference when a persisted cleanup write fails', () => { + const { settings, setValue } = makeSettings({ + user: { model: { reasoningEffort: 'ultra' } }, + }); + setValue.mockImplementationOnce(() => { + throw new Error('EACCES'); + }); + const { config, setReasoningEffort } = makeConfig('ultra'); + + expect( + clearIncompatibleReasoningEffortForModel(config, settings, 'qwen3.7-max'), + ).toBe(true); + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); + }); + + it('preserves an opaque preference when the resolved model route did not change', () => { + const routeIdentity = 'glm-5.2@same-route'; + const { settings, setValue } = makeSettings({ + user: { model: { reasoningEffort: 'Vendor.Ultra' } }, + }); + const { config, setReasoningEffort } = makeConfig( + 'Vendor.Ultra', + routeIdentity, + ); + + expect( + clearIncompatibleReasoningEffortForModel( + config, + settings, + 'glm-5.2', + true, + routeIdentity, + ), + ).toBe(false); + expect(setValue).not.toHaveBeenCalled(); + expect(setReasoningEffort).not.toHaveBeenCalled(); + }); + + it.each(['high', 'Vendor.Ultra'])( + 'clears live %s effort from a session-only toggle-only switch', + (reasoningPreference) => { + const { settings, setValue } = makeSettings({ + user: { model: { reasoningEffort: reasoningPreference } }, + }); + const { config, setReasoningEffort } = makeConfig(reasoningPreference); + + expect( + clearIncompatibleReasoningEffortForModel( + config, + settings, + 'qwen3.7-max', + false, + ), + ).toBe(true); + expect(setValue).not.toHaveBeenCalled(); + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); + }, + ); + + it.each(['system', 'systemDefaults'] as const)( + 'clears a live incompatible effort owned by the read-only %s scope', + (scope) => { + const { settings, setValue } = makeSettings({ + [scope]: { model: { reasoningEffort: 'Vendor.Ultra' } }, + }); + const { config, setReasoningEffort } = makeConfig('Vendor.Ultra'); + + expect( + clearIncompatibleReasoningEffortForModel( + config, + settings, + 'qwen3.7-max', + ), + ).toBe(true); + expect(setValue).not.toHaveBeenCalled(); + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); + }, + ); + + it('ignores workspace persistence when the workspace is untrusted', () => { + const { settings, setValue } = makeSettings({ + isTrusted: false, + workspace: { model: { reasoningEffort: 'ultra' } }, + }); + const { config, setReasoningEffort } = makeConfig(); + + expect( + clearIncompatibleReasoningEffortForModel(config, settings, 'coder-model'), + ).toBe(false); + expect(setValue).not.toHaveBeenCalled(); + expect(setReasoningEffort).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/config/reasoning-effort-persistence.ts b/packages/cli/src/config/reasoning-effort-persistence.ts new file mode 100644 index 00000000000..1632567be1d --- /dev/null +++ b/packages/cli/src/config/reasoning-effort-persistence.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createDebugLogger, + isBuiltInReasoningEffort, + isQwenFamilyWireModel, + isTieredEffortWireModel, + type Config, +} from '@qwen-code/qwen-code-core'; +import { getWritableScopes } from './modelProvidersScope.js'; +import { SettingScope, type LoadedSettings } from './settings.js'; +import { + deleteNestedPropertySafe, + settingExistsInScope, +} from './settingsUtils.js'; + +const debugLogger = createDebugLogger('REASONING_EFFORT_PERSISTENCE'); + +export function clearIncompatibleReasoningEffortForModel( + config: Config, + settings: LoadedSettings, + modelId: string, + persistSettings = true, + previousModelRouteIdentity?: string, +): boolean { + const preference = config.getReasoningPreference?.(); + if (isTieredEffortWireModel(modelId)) { + return false; + } + if ( + previousModelRouteIdentity !== undefined && + previousModelRouteIdentity === config.getModelRouteIdentity?.() + ) { + return false; + } + const hasOpaquePreference = + typeof preference === 'string' && !isBuiltInReasoningEffort(preference); + const isToggleOnlyQwen = isQwenFamilyWireModel(modelId); + const hasIncompatibleLivePreference = + typeof preference === 'string' && (isToggleOnlyQwen || hasOpaquePreference); + + const resetLivePreference = () => { + const persistedEffort = settings.merged.model?.reasoningEffort; + if (persistedEffort === 'none') { + config.disableReasoning(); + } else if (!isToggleOnlyQwen && isBuiltInReasoningEffort(persistedEffort)) { + config.setReasoningEffort(persistedEffort); + } else { + config.setReasoningEffort(undefined); + } + }; + + if (!persistSettings) { + if (!hasIncompatibleLivePreference) { + return false; + } + resetLivePreference(); + return true; + } + + const incompatibleScopes = getWritableScopes(settings).filter((scope) => { + const scopeSettings = + scope === SettingScope.Workspace + ? settings.workspace.settings + : settings.user.settings; + if (!settingExistsInScope('model.reasoningEffort', scopeSettings)) { + return false; + } + const persistedEffort = scopeSettings.model?.reasoningEffort; + return ( + typeof persistedEffort === 'string' && + persistedEffort !== 'none' && + (isToggleOnlyQwen || !isBuiltInReasoningEffort(persistedEffort)) + ); + }); + + let clearedScopeCount = 0; + for (const scope of incompatibleScopes) { + try { + settings.setValue(scope, 'model.reasoningEffort', undefined, undefined, { + throwOnWriteFailure: true, + }); + } catch (error) { + debugLogger.warn( + `Failed to clear ${scope} model.reasoningEffort after model switch: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + continue; + } + const settingsFile = + scope === SettingScope.Workspace ? settings.workspace : settings.user; + deleteNestedPropertySafe( + settingsFile.settings as Record, + 'model.reasoningEffort', + ); + deleteNestedPropertySafe( + settingsFile.originalSettings as Record, + 'model.reasoningEffort', + ); + clearedScopeCount++; + } + if (clearedScopeCount > 0) { + settings.recomputeMerged(); + } + if (hasIncompatibleLivePreference) { + resetLivePreference(); + } + return incompatibleScopes.length > 0 || hasIncompatibleLivePreference; +} diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index a3ee5010437..be67d9e0a69 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -346,6 +346,18 @@ describe('SettingsSchema', () => { }); }); + it('accepts model-specific reasoning effort strings', () => { + const reasoningEffort = + getSettingsSchema().model.properties.reasoningEffort; + + expect(reasoningEffort).toMatchObject({ + type: 'string', + default: undefined, + requiresRestart: false, + }); + expect(reasoningEffort).not.toHaveProperty('options'); + }); + it('should define telemetry sensitiveSpanAttributeMaxLength as a positive integer', () => { const telemetrySchema = getSettingsSchema().telemetry.jsonSchemaOverride; expect( diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index dcc405fada5..9c03f73d7ff 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1570,21 +1570,14 @@ const SETTINGS_SCHEMA = { showInDialog: false, }, reasoningEffort: { - type: 'enum', + type: 'string', label: 'Reasoning Effort', category: 'Model', requiresRestart: false, default: undefined as string | undefined, description: - 'How hard reasoning-capable models think, applied across all providers. Set with /effort. Each provider maps and clamps this to what the active model supports (e.g. Gemini caps at "high"; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default.', + 'How hard reasoning-capable models think. Values are model-specific; use "none" to disable reasoning and leave unset to use the model/provider default.', showInDialog: true, - options: [ - { value: 'low', label: 'Low' }, - { value: 'medium', label: 'Medium' }, - { value: 'high', label: 'High' }, - { value: 'xhigh', label: 'Extra High' }, - { value: 'max', label: 'Max' }, - ], }, maxSessionTurns: { type: 'integer', diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index 7eb457c65e1..30f22b2be32 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -26,6 +26,7 @@ import { } from './ui/commands/types.js'; import { filterCommandsForMode } from './services/commandUtils.js'; import { goalCommand } from './ui/commands/goalCommand.js'; +import { effortCommand } from './ui/commands/effort-command.js'; const recordAutoSkillUsageMock = vi.hoisted(() => vi.fn()); vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ @@ -162,6 +163,50 @@ describe('handleSlashCommand', () => { expect(result.type).toBe('no_command'); }); + it('forwards a real built-in effort persistence event through the session hook', async () => { + const setValue = vi.fn(); + const notifySettingsChanged = vi.fn(); + Object.assign(mockConfig, { + getReasoningEffort: vi.fn().mockReturnValue('high'), + getReasoningPreference: vi.fn().mockReturnValue(undefined), + getReasoningEffortOverride: vi.fn().mockReturnValue(undefined), + setReasoningEffort: vi.fn(), + }); + mockSettings = { + ...mockSettings, + isTrusted: true, + user: { path: '', settings: {} }, + workspace: { path: '', settings: {} }, + setValue, + } as unknown as LoadedSettings; + mockGetCommands.mockReturnValue([effortCommand]); + + const result = await handleSlashCommand( + '/effort high', + abortController, + mockConfig, + mockSettings, + { notifySettingsChanged }, + ); + + expect(result).toMatchObject({ + type: 'message', + resolvedCommand: { name: 'effort', kind: CommandKind.BUILT_IN }, + }); + expect(setValue).toHaveBeenCalledWith( + expect.anything(), + 'model.reasoningEffort', + 'high', + undefined, + { throwOnWriteFailure: true }, + ); + expect(notifySettingsChanged).toHaveBeenCalledWith({ + key: 'model.reasoningEffort', + value: 'high', + scope: 'user', + }); + }); + it('should return unsupported for built-in commands without non-interactive supportedModes', async () => { const mockHelpCommand = { name: 'help', diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 65a85cfdbf4..783632b7179 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -395,6 +395,8 @@ async function registerModelInvocableCommands( export interface NonInteractiveSlashCommandSessionHooks { /** @see CommandContext['session']['startNewSession'] */ startNewSession?: (sessionId: string) => void; + /** @see CommandContext['session']['notifySettingsChanged'] */ + notifySettingsChanged?: CommandContext['session']['notifySettingsChanged']; } export const handleSlashCommand = async ( @@ -649,6 +651,9 @@ export const handleSlashCommand = async ( ...(sessionHooks?.startNewSession ? { startNewSession: sessionHooks.startNewSession } : {}), + ...(sessionHooks?.notifySettingsChanged + ? { notifySettingsChanged: sessionHooks.notifySettingsChanged } + : {}), }, invocation: { raw: trimmed, diff --git a/packages/cli/src/serve/routes/session.ts b/packages/cli/src/serve/routes/session.ts index 3ff9d3e2e15..66b490af053 100644 --- a/packages/cli/src/serve/routes/session.ts +++ b/packages/cli/src/serve/routes/session.ts @@ -135,6 +135,7 @@ import { } from '../skill-details-redaction.js'; import { replayTranscriptRecordPage } from '../../acp-integration/session/history-replay-page.js'; import { GENERATION_MAX_PROMPT_BYTES } from '../../acp-integration/generation.js'; +import { PERSIST_REASONING_EFFORT_META_KEY } from '@qwen-code/acp-bridge/reasoningPersistence'; import { formatGenerationSse, GENERATION_HEARTBEAT_MS, @@ -6756,7 +6757,12 @@ export function registerSessionRoutes( } const response = await runtime.bridge.setSessionConfigOption( sessionId, - { sessionId, configId, value }, + { + sessionId, + configId, + value, + _meta: { [PERSIST_REASONING_EFFORT_META_KEY]: true }, + }, ); res.status(200).json(response); }, diff --git a/packages/cli/src/serve/run-qwen-serve.test.ts b/packages/cli/src/serve/run-qwen-serve.test.ts index f2ab1987b3b..03d4fa6c08d 100644 --- a/packages/cli/src/serve/run-qwen-serve.test.ts +++ b/packages/cli/src/serve/run-qwen-serve.test.ts @@ -3854,8 +3854,29 @@ describe('runQwenServe telemetry validation', () => { ).toBeTypeOf('function'); expect(createBridge.mock.calls[1]?.[0]).toMatchObject({ permissionPolicy: 'local-only', + publishGlobalWorkspaceEvent: expect.any(Function), sessionRestoreTimeoutMs: 90_000, }); + expect(createBridge.mock.calls[1]?.[0].publishGlobalWorkspaceEvent).toBe( + createBridge.mock.calls[0]?.[0].publishGlobalWorkspaceEvent, + ); + const settingsChangedEvent = { + type: 'settings_changed' as const, + data: { + key: 'model.reasoningEffort', + value: 'high', + scope: 'user', + }, + }; + createBridge.mock.calls[1]?.[0].publishGlobalWorkspaceEvent?.( + settingsChangedEvent, + ); + expect( + createBridge.mock.results[0]?.value.publishWorkspaceEvent, + ).toHaveBeenCalledWith(settingsChangedEvent); + expect( + createBridge.mock.results[1]?.value.publishWorkspaceEvent, + ).toHaveBeenCalledWith(settingsChangedEvent); // The dynamically attached workspace's bridge must carry the same // adaptive-growth pool as the boot bridge — the budget here is // host-derived, so assert parity, not a fixed figure. @@ -4118,7 +4139,7 @@ describe('runQwenServe telemetry validation', () => { } }); - it('uses the daemon-wide policy and limits when constructing workspace bridges', async () => { + it('uses daemon-wide policy, limits, and user-settings fan-out for workspace bridges', async () => { mockCreateSpawnChannelFactoryOptions.length = 0; tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'qws-ws-'))); const primary = path.join(tmpDir, 'primary'); @@ -4168,6 +4189,7 @@ describe('runQwenServe telemetry validation', () => { }, } : {}, + setValue: vi.fn(), } as unknown as ReturnType; }, ); @@ -4181,6 +4203,7 @@ describe('runQwenServe telemetry validation', () => { hostname: '127.0.0.1', mode: 'http-bridge', workspace: [primary, secondary], + token: 'fanout-token', maxSessions: 1, eventRingSize: 1234, compactedReplayMaxBytes: 1024, @@ -4210,6 +4233,7 @@ describe('runQwenServe telemetry validation', () => { sessionRestoreTimeoutMs: 90_000, permissionPolicy: 'local-only', onChannelDelivery: expect.any(Function), + publishGlobalWorkspaceEvent: expect.any(Function), }); expect(createBridge.mock.calls[1]?.[0]).toMatchObject({ compactedReplayMaxBytes: 1024, @@ -4217,7 +4241,83 @@ describe('runQwenServe telemetry validation', () => { sessionRestoreTimeoutMs: 90_000, permissionPolicy: 'local-only', onChannelDelivery: expect.any(Function), + publishGlobalWorkspaceEvent: expect.any(Function), + }); + expect(createBridge.mock.calls[1]?.[0].publishGlobalWorkspaceEvent).toBe( + createBridge.mock.calls[0]?.[0].publishGlobalWorkspaceEvent, + ); + const settingsChangedEvent = { + type: 'settings_changed' as const, + data: { + key: 'model.reasoningEffort', + value: 'medium', + scope: 'user', + }, + }; + createBridge.mock.calls[0]?.[0].publishGlobalWorkspaceEvent?.( + settingsChangedEvent, + ); + expect(primaryBridge.publishWorkspaceEvent).toHaveBeenCalledWith( + settingsChangedEvent, + ); + expect(secondaryBridge.publishWorkspaceEvent).toHaveBeenCalledWith( + settingsChangedEvent, + ); + + vi.mocked(primaryBridge.publishWorkspaceEvent).mockClear(); + vi.mocked(secondaryBridge.publishWorkspaceEvent).mockClear(); + const userSetting = await fetch(`${handle.url}/workspace/settings`, { + method: 'POST', + headers: { + Authorization: 'Bearer fanout-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + scope: 'user', + key: 'model.reasoningEffort', + value: 'none', + }), + }); + expect(userSetting.status).toBe(200); + const userSettingEvent = { + type: 'settings_changed' as const, + data: { + key: 'model.reasoningEffort', + value: 'none', + scope: 'user', + }, + }; + expect(primaryBridge.publishWorkspaceEvent).toHaveBeenCalledWith( + userSettingEvent, + ); + expect(secondaryBridge.publishWorkspaceEvent).toHaveBeenCalledWith( + userSettingEvent, + ); + + vi.mocked(primaryBridge.publishWorkspaceEvent).mockClear(); + vi.mocked(secondaryBridge.publishWorkspaceEvent).mockClear(); + const workspaceSetting = await fetch(`${handle.url}/workspace/settings`, { + method: 'POST', + headers: { + Authorization: 'Bearer fanout-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + scope: 'workspace', + key: 'model.reasoningEffort', + value: 'medium', + }), + }); + expect(workspaceSetting.status).toBe(200); + expect(primaryBridge.publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: 'medium', + scope: 'workspace', + }, }); + expect(secondaryBridge.publishWorkspaceEvent).not.toHaveBeenCalled(); expect(createBridge.mock.calls[1]?.[0]).not.toHaveProperty( 'permissionConsensusQuorum', ); diff --git a/packages/cli/src/serve/run-qwen-serve.ts b/packages/cli/src/serve/run-qwen-serve.ts index d470502b46d..1049eb0155c 100644 --- a/packages/cli/src/serve/run-qwen-serve.ts +++ b/packages/cli/src/serve/run-qwen-serve.ts @@ -4929,6 +4929,13 @@ async function runQwenServeImpl( // `mcp_register`). Inert unless `opts.clientMcpOverWs` is on. const clientMcpSenderRegistry = new ClientMcpSenderRegistry(); const runtimeBridges: AcpSessionBridge[] = []; + const publishGlobalWorkspaceEvent = ( + event: Omit, + ): void => { + for (const runtimeBridge of runtimeBridges) { + runtimeBridge.publishWorkspaceEvent(event); + } + }; const totalSessionAdmission = runtime.createTotalSessionAdmissionController( { maxTotalSessions: opts.maxTotalSessions, @@ -5252,6 +5259,7 @@ async function runQwenServeImpl( const bridge = deps.bridge ?? runtime.createAcpSessionBridge({ + publishGlobalWorkspaceEvent, sessionAttachmentsRoot: sessionAttachmentsRoot( boundWorkspace, primarySessionRuntimeBaseDir, @@ -5706,6 +5714,7 @@ async function runQwenServeImpl( ), }); const secondaryBridge = runtime.createAcpSessionBridge({ + publishGlobalWorkspaceEvent, sessionAttachmentsRoot: sessionAttachmentsRoot( workspaceInput.cwd, secondaryEnv.sessionRuntimeBaseDir, @@ -6326,6 +6335,7 @@ async function runQwenServeImpl( let wsBridge: ReturnType; try { wsBridge = runtime.createAcpSessionBridge({ + publishGlobalWorkspaceEvent, sessionAttachmentsRoot: sessionAttachmentsRoot( cwd, wsEnv.sessionRuntimeBaseDir, @@ -7020,6 +7030,7 @@ async function runQwenServeImpl( liveDiscoveryStableBaseDir, workspaceRegistry, getSessionBridges: () => runtimeBridges, + publishGlobalWorkspaceEvent, createWorkspaceRuntime: createDynamicWorkspaceRuntime, ...(workspaceTrustHotReloadAvailable ? { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 3b6ee795c98..cb2faeccd80 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -20206,10 +20206,43 @@ describe('createServeApp', () => { sessionId: 'session-A', configId: 'reasoning_effort', value: 'medium', + _meta: { 'qwenCode/persistReasoningEffort': true }, }, }, ]); }); + + it('marks default as a persisted setting deletion', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + + const res = await request(app) + .post('/session/session-A/config-option') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ configId: 'reasoning_effort', value: 'default' }); + + expect(res.status).toBe(200); + expect(bridge.setConfigOptionCalls[0]?.req).toMatchObject({ + value: 'default', + _meta: { 'qwenCode/persistReasoningEffort': true }, + }); + }); + + it('rejects config options other than reasoning effort', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + + const res = await request(app) + .post('/session/session-A/config-option') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ configId: 'mode', value: 'plan' }); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + error: '`configId` must be reasoning_effort', + }); + expect(bridge.setConfigOptionCalls).toEqual([]); + }); }); describe('POST /session/:id/recap (#4175 follow-up)', () => { diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index c01b9c37911..21df54d573d 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -8,6 +8,7 @@ import express from 'express'; import type { Application } from 'express'; import * as path from 'node:path'; import type { DaemonStatusProvider } from '@qwen-code/acp-bridge'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; import { hashDaemonWorkspace, readCronTasks, @@ -606,6 +607,7 @@ export interface ServeAppDeps { * generations no longer exposed by the workspace registry. */ getSessionBridges?: () => readonly AcpSessionBridge[]; + publishGlobalWorkspaceEvent?: (event: Omit) => void; workspaceTrustHotReloadAvailable?: boolean; getWorkspaceTrustPolicySnapshot?: () => | DaemonTrustPolicySnapshot @@ -2477,11 +2479,16 @@ export function createServeApp( clientId: string | undefined, ) => { invalidateServeFeaturesCache(); - primaryBridge.publishWorkspaceEvent({ + const event = { type: 'settings_changed', data: { key, value, scope }, ...(clientId ? { originatorClientId: clientId } : {}), - }); + } as const; + if (scope === 'user' && deps.publishGlobalWorkspaceEvent) { + deps.publishGlobalWorkspaceEvent(event); + } else { + primaryBridge.publishWorkspaceEvent(event); + } }; if (deps.persistSetting) { diff --git a/packages/cli/src/serve/workspace-providers-status.test.ts b/packages/cli/src/serve/workspace-providers-status.test.ts index 1658ca5deed..690c29dff89 100644 --- a/packages/cli/src/serve/workspace-providers-status.test.ts +++ b/packages/cli/src/serve/workspace-providers-status.test.ts @@ -277,7 +277,7 @@ describe('createWorkspaceProvidersStatusProvider', () => { const provider = createWorkspaceProvidersStatusProvider({ env: {} }); await writeUserSettings({ security: { auth: { selectedType: 'openai' } }, - model: { name: 'qwen3.8-max' }, + model: { name: 'qwen3.8-max', reasoningEffort: 'medium' }, modelProviders: { openai: [ { @@ -299,8 +299,13 @@ describe('createWorkspaceProvidersStatusProvider', () => { expect(stable?.configOptions).toMatchObject([ { id: 'reasoning_effort', - currentValue: 'xhigh', - options: [{ value: 'low' }, { value: 'medium' }, { value: 'xhigh' }], + currentValue: 'medium', + options: [ + { value: 'default' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + ], _meta: { 'qwenCode/reasoning': { defaultEffort: 'xhigh', @@ -316,6 +321,142 @@ describe('createWorkspaceProvidersStatusProvider', () => { ).toBe(true); }); + it('projects disabled reasoning from persisted settings', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { name: 'qwen3.8-max', reasoningEffort: 'none' }, + modelProviders: { + openai: [{ id: 'qwen3.8-max', name: 'Qwen 3.8 Max' }], + }, + }); + + const result = await provider(workspace, false); + const stable = result.providers + .flatMap((entry) => entry.models) + .find((model) => model.baseModelId === 'qwen3.8-max'); + + expect(stable?.configOptions).toMatchObject([ + { id: 'reasoning_effort', currentValue: 'none' }, + ]); + }); + + it('projects effort-only controls for thinking-mandatory models', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { name: 'qwen3.8-max', reasoningEffort: 'none' }, + modelProviders: { + openai: [ + { + id: 'qwen3.8-max', + name: 'Qwen 3.8 Max', + generationConfig: { thinkingMandatory: true }, + }, + ], + }, + }); + + const result = await provider(workspace, false); + const stable = result.providers + .flatMap((entry) => entry.models) + .find((model) => model.baseModelId === 'qwen3.8-max'); + + expect(stable?.configOptions).toMatchObject([ + { + id: 'reasoning_effort', + currentValue: 'xhigh', + options: [ + { value: 'default' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + ], + _meta: { + 'qwenCode/reasoning': { + defaultEffort: 'xhigh', + thinkingMandatory: true, + }, + }, + }, + ]); + }); + + it('uses the target model provider reasoning default when no global effort is configured', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { name: 'thinking-off-model' }, + modelProviders: { + openai: [ + { + id: 'thinking-off-model', + name: 'Thinking Off Model', + generationConfig: { reasoning: false }, + }, + { + id: 'qwen3.8-max', + name: 'Qwen 3.8 Max', + generationConfig: { reasoning: false }, + }, + ], + }, + }); + + const result = await provider(workspace, false); + const stable = result.providers + .flatMap((entry) => entry.models) + .find((model) => model.baseModelId === 'qwen3.8-max'); + + expect(stable?.configOptions).toMatchObject([ + { id: 'reasoning_effort', currentValue: 'none' }, + ]); + }); + + it('preserves a target model provider custom reasoning default in the preview', async () => { + const provider = createWorkspaceProvidersStatusProvider({ env: {} }); + await writeUserSettings({ + security: { auth: { selectedType: 'openai' } }, + model: { name: 'qwen3.8-max' }, + modelProviders: { + openai: [ + { + id: 'qwen3.8-max', + name: 'Qwen 3.8 Max', + generationConfig: { + reasoning: { effort: 'provider.Ultra' }, + }, + }, + ], + }, + }); + + const result = await provider(workspace, false); + const stable = result.providers + .flatMap((entry) => entry.models) + .find((model) => model.baseModelId === 'qwen3.8-max'); + + const reasoningOption = stable?.configOptions?.[0] as + | { + id?: string; + currentValue?: string; + options?: Array<{ value: string; name: string }>; + } + | undefined; + expect(reasoningOption).toMatchObject({ + id: 'reasoning_effort', + currentValue: 'provider.Ultra', + }); + expect(reasoningOption?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: 'provider.Ultra', + name: 'provider.Ultra', + }), + ]), + ); + }); + it('does not project reasoning preview onto opaque route models', async () => { const provider = createWorkspaceProvidersStatusProvider({ env: {} }); await writeUserSettings({ diff --git a/packages/cli/src/serve/workspace-providers-status.ts b/packages/cli/src/serve/workspace-providers-status.ts index 56425c42c91..2fcda37891c 100644 --- a/packages/cli/src/serve/workspace-providers-status.ts +++ b/packages/cli/src/serve/workspace-providers-status.ts @@ -33,7 +33,10 @@ import { parseAcpBaseModelId, sanitizeProviderBaseUrl, } from '../utils/acpModelUtils.js'; -import { buildModelReasoningConfigPreview } from '../acp-integration/model-configuration.js'; +import { + buildModelReasoningConfigPreview, + resolveReasoningPreviewState, +} from '../acp-integration/model-configuration.js'; import { snapshotProcessEnv } from './env-snapshot.js'; const debugLogger = createDebugLogger('WORKSPACE_PROVIDERS_STATUS'); @@ -126,6 +129,7 @@ function buildWorkspaceProvidersStatus( const modelOptions = buildAcpModelOptions( modelsConfig.getAllConfiguredModels(), ); + const reasoningPreference = resolvedCliConfig.reasoningPreference; const currentAcpModelId = hasCurrentModel ? getCurrentAcpModelId( modelOptions, @@ -163,15 +167,19 @@ function buildWorkspaceProvidersStatus( const isCurrent = currentAuth === model.authType && currentAcpModelId === modelId; + const resolvedGeneration = modelsConfig.getResolvedModel( + model.authType, + model.id, + model.registryBaseUrl ?? model.baseUrl, + )?.generationConfig; const configOptions = modelId.startsWith(ACP_ROUTE_ID_PREFIX) ? undefined : buildModelReasoningConfigPreview(model.id, { - thinkingMandatory: - modelsConfig.getResolvedModel( - model.authType, - model.id, - model.registryBaseUrl ?? model.baseUrl, - )?.generationConfig.thinkingMandatory === true, + ...resolveReasoningPreviewState( + reasoningPreference, + resolvedGeneration?.reasoning, + ), + thinkingMandatory: resolvedGeneration?.thinkingMandatory === true, }); const providerModel: ServeWorkspaceProviderModel = { modelId, diff --git a/packages/cli/src/ui/commands/effort-command.test.ts b/packages/cli/src/ui/commands/effort-command.test.ts index 4d98fcd67c8..f689abe6e6c 100644 --- a/packages/cli/src/ui/commands/effort-command.test.ts +++ b/packages/cli/src/ui/commands/effort-command.test.ts @@ -18,7 +18,10 @@ vi.mock('../../i18n/index.js', () => ({ describe('effortCommand', () => { let setReasoningEffort: ReturnType; let getReasoningEffort: ReturnType; + let getReasoningPreference: ReturnType; + let getContentGeneratorConfig: ReturnType; let setValue: ReturnType; + let notifySettingsChanged: ReturnType; let context: CommandContext; beforeEach(() => { @@ -29,11 +32,16 @@ describe('effortCommand', () => { currentEffort = effort; }); getReasoningEffort = vi.fn(() => currentEffort); + getReasoningPreference = vi.fn(); + getContentGeneratorConfig = vi.fn().mockReturnValue({}); setValue = vi.fn(); + notifySettingsChanged = vi.fn(); context = createMockCommandContext({ services: { config: { getReasoningEffort, + getReasoningPreference, + getContentGeneratorConfig, setReasoningEffort, getReasoningEffortOverride: vi.fn().mockReturnValue(undefined), } as unknown as Config, @@ -44,6 +52,7 @@ describe('effortCommand', () => { workspace: { settings: {} }, } as never, }, + session: { notifySettingsChanged }, }); }); @@ -53,6 +62,41 @@ describe('effortCommand', () => { expect(setReasoningEffort).not.toHaveBeenCalled(); }); + it('reports explicit disabled reasoning separately from an unset effort', async () => { + getReasoningPreference.mockReturnValue(false); + const nonInteractive = { ...context, executionMode: 'non_interactive' }; + + const res = await effortCommand.action!( + nonInteractive as typeof context, + '', + ); + + expect((res as { content: string }).content).toContain( + 'Thinking is disabled.', + ); + expect((res as { content: string }).content).not.toContain( + 'using the model/provider default', + ); + }); + + it('does not report a dormant disabled preference as active for a mandatory-thinking model', async () => { + getReasoningPreference.mockReturnValue(false); + getContentGeneratorConfig.mockReturnValue({ thinkingMandatory: true }); + const nonInteractive = { ...context, executionMode: 'non_interactive' }; + + const res = await effortCommand.action!( + nonInteractive as typeof context, + '', + ); + + expect((res as { content: string }).content).toContain( + 'using the model/provider default', + ); + expect((res as { content: string }).content).not.toContain( + 'Thinking is disabled.', + ); + }); + it('lists tiers when called with no args non-interactively', async () => { const nonInteractive = { ...context, executionMode: 'non_interactive' }; const res = await effortCommand.action!( @@ -71,7 +115,14 @@ describe('effortCommand', () => { expect.anything(), 'model.reasoningEffort', 'high', + undefined, + { throwOnWriteFailure: true }, ); + expect(notifySettingsChanged).toHaveBeenCalledWith({ + key: 'model.reasoningEffort', + value: 'high', + scope: 'user', + }); expect(res).toMatchObject({ messageType: 'info' }); }); @@ -91,9 +142,33 @@ describe('effortCommand', () => { expect(setReasoningEffort).toHaveBeenCalledWith('high'); expect(setValue).not.toHaveBeenCalled(); + expect(notifySettingsChanged).not.toHaveBeenCalled(); expect(res).toMatchObject({ messageType: 'info' }); }); + it('reports the workspace scope selected for persistence', async () => { + context.services.settings.workspace.settings = { modelProviders: {} }; + + await effortCommand.action!(context, 'medium'); + + expect(notifySettingsChanged).toHaveBeenCalledWith({ + key: 'model.reasoningEffort', + value: 'medium', + scope: 'workspace', + }); + }); + + it('does not notify when the settings write fails', async () => { + setValue.mockImplementationOnce(() => { + throw new Error('disk full'); + }); + + await expect(effortCommand.action!(context, 'medium')).rejects.toThrow( + 'disk full', + ); + expect(notifySettingsChanged).not.toHaveBeenCalled(); + }); + it('reports thinking is disabled when setReasoningEffort is a no-op', async () => { // Simulate `reasoning: false`: setReasoningEffort no-ops, so the tier never // lands. The command must still persist it but report it has not taken @@ -107,6 +182,8 @@ describe('effortCommand', () => { expect.anything(), 'model.reasoningEffort', 'high', + undefined, + { throwOnWriteFailure: true }, ); expect(res).toMatchObject({ messageType: 'info' }); expect((res as { content: string }).content).toContain( @@ -151,6 +228,8 @@ describe('effortCommand', () => { expect.anything(), 'model.reasoningEffort', 'max', + undefined, + { throwOnWriteFailure: true }, ); expect(res).toMatchObject({ messageType: 'info' }); expect((res as { content: string }).content).toContain('higher priority'); diff --git a/packages/cli/src/ui/commands/effort-command.ts b/packages/cli/src/ui/commands/effort-command.ts index 439e012ec46..3b51a9f5839 100644 --- a/packages/cli/src/ui/commands/effort-command.ts +++ b/packages/cli/src/ui/commands/effort-command.ts @@ -13,6 +13,7 @@ import type { import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { SettingScope } from '../../config/settings.js'; import { applyReasoningEffort, normalizeReasoningEffort, @@ -62,18 +63,28 @@ export const effortCommand: SlashCommand = { return { type: 'dialog', dialog: 'effort' }; } const current = config.getReasoningEffort(); + const preference = + config.getContentGeneratorConfig()?.thinkingMandatory === true + ? undefined + : config.getReasoningPreference(); return { type: 'message', messageType: 'info', - content: current - ? t( - 'Current reasoning effort: {{current}}\nAvailable: {{tiers}}\nUse "/effort " to change it.', - { current, tiers: TIER_LIST }, - ) - : t( - 'Reasoning effort: not set (using the model/provider default).\nAvailable: {{tiers}}\nUse "/effort " to set it.', - { tiers: TIER_LIST }, - ), + content: + preference === false + ? t( + 'Thinking is disabled.\nAvailable: {{tiers}}\nUse "/effort " to set a future default.', + { tiers: TIER_LIST }, + ) + : current + ? t( + 'Current reasoning effort: {{current}}\nAvailable: {{tiers}}\nUse "/effort " to change it.', + { current, tiers: TIER_LIST }, + ) + : t( + 'Reasoning effort: not set (using the model/provider default).\nAvailable: {{tiers}}\nUse "/effort " to set it.', + { tiers: TIER_LIST }, + ), }; } @@ -101,11 +112,19 @@ export const effortCommand: SlashCommand = { // Provider adapters clamp the tier to what the active model supports. applyReasoningEffort(config, tier); if (context.executionPolicy?.persistModelSelection !== false) { + const persistScope = getPersistScopeForModelSelection(settings); settings.setValue( - getPersistScopeForModelSelection(settings), + persistScope, 'model.reasoningEffort', tier, + undefined, + { throwOnWriteFailure: true }, ); + context.session.notifySettingsChanged?.({ + key: 'model.reasoningEffort', + value: tier, + scope: persistScope === SettingScope.Workspace ? 'workspace' : 'user', + }); } return { diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 73eb3dbed2f..bd18d2bdab6 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -22,16 +22,21 @@ function createMockConfig( ): Partial { return { getContentGeneratorConfig: vi.fn().mockReturnValue(contentGeneratorConfig), + getReasoningPreference: vi.fn().mockReturnValue(undefined), }; } -function createMockSettings(setValue = vi.fn()): Partial { +function createMockSettings( + setValue = vi.fn(), + user: Record = {}, +): Partial { return { merged: {}, - user: { settings: {} }, - workspace: { settings: {} }, + user: { settings: user, originalSettings: structuredClone(user) }, + workspace: { settings: {}, originalSettings: {} }, isTrusted: false, setValue, + recomputeMerged: vi.fn(), } as unknown as Partial; } @@ -257,6 +262,7 @@ describe('modelCommand', () => { it('should switch a dual-role model directly in interactive mode', async () => { const setValue = vi.fn(); + const setReasoningEffort = vi.fn(); const switchModel = vi.fn().mockResolvedValue(undefined); const recordSessionModel = vi.fn().mockResolvedValue(true); mockContext = createMockCommandContext({ @@ -275,11 +281,15 @@ describe('modelCommand', () => { }, ]), switchModel, + setReasoningEffort, + getReasoningPreference: vi.fn().mockReturnValue('ultra'), getChatRecordingService: vi.fn().mockReturnValue({ recordSessionModel, }), }, - settings: createMockSettings(setValue), + settings: createMockSettings(setValue, { + model: { reasoningEffort: 'ultra' }, + }), }, }); @@ -303,6 +313,14 @@ describe('modelCommand', () => { 'model.baseUrl', '', ); + expect(setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.reasoningEffort', + undefined, + undefined, + { throwOnWriteFailure: true }, + ); + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); expect(result).toEqual({ type: 'message', messageType: 'info', @@ -311,6 +329,48 @@ describe('modelCommand', () => { expect(recordSessionModel).not.toHaveBeenCalled(); }); + it('preserves an opaque effort when reselecting the same model route', async () => { + const setValue = vi.fn(); + const setReasoningEffort = vi.fn(); + const switchModel = vi.fn().mockResolvedValue(undefined); + mockContext = createMockCommandContext({ + invocation: { raw: '/model glm-5.2', name: 'model', args: 'glm-5.2' }, + services: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + model: 'glm-5.2', + authType: AuthType.USE_OPENAI, + }), + getAvailableModelsForAuthType: vi + .fn() + .mockReturnValue([{ id: 'glm-5.2', label: 'GLM 5.2' }]), + getModelRouteIdentity: vi.fn().mockReturnValue('glm-5.2@same-route'), + getReasoningPreference: vi.fn().mockReturnValue('Vendor.Ultra'), + switchModel, + setReasoningEffort, + getChatRecordingService: vi.fn().mockReturnValue({ + recordSessionModel: vi.fn().mockResolvedValue(true), + }), + }, + settings: createMockSettings(setValue, { + model: { reasoningEffort: 'Vendor.Ultra' }, + }), + }, + }); + + await modelCommand.action!(mockContext, 'glm-5.2'); + + expect(switchModel).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'glm-5.2', + undefined, + ); + expect( + setValue.mock.calls.some(([, key]) => key === 'model.reasoningEffort'), + ).toBe(false); + expect(setReasoningEffort).not.toHaveBeenCalled(); + }); + it('records the session model in ACP mode after switching', async () => { const setValue = vi.fn(); let currentModel = 'old-model'; @@ -331,6 +391,7 @@ describe('modelCommand', () => { .fn() .mockReturnValue([{ id: 'qwen-max', label: 'Qwen Max' }]), switchModel, + getReasoningPreference: vi.fn().mockReturnValue(undefined), getModel: vi.fn(() => currentModel), getAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined), @@ -356,6 +417,7 @@ describe('modelCommand', () => { it('switches the standalone session model without persisting defaults', async () => { const setValue = vi.fn(); + const setReasoningEffort = vi.fn(); const switchModel = vi.fn().mockResolvedValue(undefined); mockContext = createMockCommandContext({ executionMode: 'acp', @@ -376,6 +438,8 @@ describe('modelCommand', () => { .fn() .mockReturnValue([{ id: 'qwen-max', label: 'Qwen Max' }]), switchModel, + setReasoningEffort, + getReasoningPreference: vi.fn().mockReturnValue('ultra'), getModel: vi.fn().mockReturnValue('qwen-max'), getAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH), getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined), @@ -384,7 +448,9 @@ describe('modelCommand', () => { recordSessionModel: vi.fn().mockResolvedValue(true), }), }, - settings: createMockSettings(setValue), + settings: createMockSettings(setValue, { + model: { reasoningEffort: 'ultra' }, + }), }, }); @@ -396,6 +462,7 @@ describe('modelCommand', () => { undefined, ); expect(setValue).not.toHaveBeenCalled(); + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); }); it('rejects standalone model scope and auxiliary selectors before mutation', async () => { @@ -832,6 +899,7 @@ describe('modelCommand', () => { .fn() .mockReturnValue([{ id: 'gpt-4', label: 'GPT-4' }]), switchModel, + getReasoningPreference: vi.fn().mockReturnValue(undefined), }, settings: createMockSettings(setValue), }, @@ -2044,6 +2112,7 @@ describe('modelCommand', () => { .fn() .mockReturnValue([{ id: '--fast-model', label: '--fast-model' }]), switchModel, + getReasoningPreference: vi.fn().mockReturnValue(undefined), }, settings: createMockSettings(setValue), }, diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index bd348c35732..de07af47032 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -14,6 +14,7 @@ import type { import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { clearIncompatibleReasoningEffortForModel } from '../../config/reasoning-effort-persistence.js'; import { AuthType, type AvailableModel, @@ -137,6 +138,7 @@ async function switchMainModel( persistSelection = true, ): Promise { const parsed = parseAcpModelOption(modelArg); + const previousModelRouteIdentity = config.getModelRouteIdentity?.(); if (parsed.authType) { await config.switchModel( @@ -147,6 +149,13 @@ async function switchMainModel( ? { requireCachedCredentials: true } : undefined, ); + clearIncompatibleReasoningEffortForModel( + config, + settings, + parsed.modelId, + persistSelection, + previousModelRouteIdentity, + ); if (persistSelection) { persistSetting( settings, @@ -166,6 +175,13 @@ async function switchMainModel( } await config.switchModel(currentAuthType, modelArg, undefined); + clearIncompatibleReasoningEffortForModel( + config, + settings, + modelArg, + persistSelection, + previousModelRouteIdentity, + ); if (persistSelection) { persistSetting(settings, 'model.name', modelArg, scopeOverride); persistSetting(settings, 'model.baseUrl', '', scopeOverride); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 82377a0d980..29c50b0e31d 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -125,6 +125,11 @@ export interface CommandContext { sessionShellAllowlist: Set; /** Reset session metrics and prompt counters for a fresh session. */ startNewSession?: (sessionId: string) => void; + notifySettingsChanged?: (change: { + key: string; + value: unknown; + scope: 'user' | 'workspace'; + }) => void; }; // Flag to indicate if an overwrite has been confirmed overwriteConfirmed?: boolean; diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f60a5388a02..97aefe484c9 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -356,7 +356,10 @@ export const DialogManager = ({ return ( diff --git a/packages/cli/src/ui/components/EffortDialog.test.tsx b/packages/cli/src/ui/components/EffortDialog.test.tsx index e23e32ca7e0..6fc061ea62f 100644 --- a/packages/cli/src/ui/components/EffortDialog.test.tsx +++ b/packages/cli/src/ui/components/EffortDialog.test.tsx @@ -34,7 +34,7 @@ describe('EffortDialog', () => { expect(frame).toContain('Use Enter to select, Esc to cancel'); }); - it('shows the "no effort configured" hint when currentEffort is unset', () => { + it('shows the "no effort configured" hint when the preference is unset', () => { const { lastFrame } = renderWithProviders( , ); @@ -44,14 +44,40 @@ describe('EffortDialog', () => { ); }); - it('hides the "no effort configured" hint when currentEffort is set', () => { + it('hides the "no effort configured" hint when a preference is set', () => { const { lastFrame } = renderWithProviders( - , + , ); expect(lastFrame() ?? '').not.toContain('No effort configured'); }); + it('distinguishes disabled thinking from an unset preference', () => { + const { lastFrame } = renderWithProviders( + , + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain('Thinking is disabled.'); + expect(frame).not.toContain('No effort configured'); + }); + + it('shows a dormant disabled preference as the model default when thinking is mandatory', () => { + const { lastFrame } = renderWithProviders( + , + ); + + const frame = lastFrame() ?? ''; + expect(frame).toContain( + 'No effort configured — using the model/provider default.', + ); + expect(frame).not.toContain('Thinking is disabled.'); + }); + it('registers an active Escape handler that cancels with undefined', () => { const onSelect = vi.fn(); renderWithProviders(); diff --git a/packages/cli/src/ui/components/EffortDialog.tsx b/packages/cli/src/ui/components/EffortDialog.tsx index 9d57d971902..4c16033caae 100644 --- a/packages/cli/src/ui/components/EffortDialog.tsx +++ b/packages/cli/src/ui/components/EffortDialog.tsx @@ -20,8 +20,10 @@ interface EffortDialogProps { /** Callback when a tier is chosen; `undefined` means the dialog was cancelled. */ onSelect: (effort: ReasoningEffort | undefined) => void; - /** The currently active effort, used to pre-select the list. */ - currentEffort?: ReasoningEffort; + /** The explicit preference; false disables thinking when the model allows it. */ + currentPreference?: false | string; + + thinkingMandatory?: boolean; } const EFFORT_DESCRIPTIONS: Record = { @@ -34,8 +36,15 @@ const EFFORT_DESCRIPTIONS: Record = { export function EffortDialog({ onSelect, - currentEffort, + currentPreference, + thinkingMandatory = false, }: EffortDialogProps): React.JSX.Element { + const activePreference = + thinkingMandatory && currentPreference === false + ? undefined + : currentPreference; + const currentEffort = + activePreference === false ? undefined : activePreference; const items = REASONING_EFFORT_TIERS.map((tier) => ({ label: `${tier} — ${t(EFFORT_DESCRIPTIONS[tier])}`, value: tier, @@ -47,7 +56,10 @@ export function EffortDialog({ // which would mislead the user into thinking 'high' is their current setting // when in fact the model/provider default applies. const initialIndex = currentEffort - ? Math.max(0, REASONING_EFFORT_TIERS.indexOf(currentEffort)) + ? Math.max( + 0, + REASONING_EFFORT_TIERS.findIndex((tier) => tier === currentEffort), + ) : 0; const handleSelect = useCallback( @@ -89,13 +101,19 @@ export function EffortDialog({ isFocused showNumbers /> - {!currentEffort && ( + {activePreference === false ? ( + + + {t('Thinking is disabled.')} + + + ) : activePreference === undefined ? ( {t('No effort configured — using the model/provider default.')} - )} + ) : null} {t('(Use Enter to select, Esc to cancel)')} diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index 618d97f5b3f..b4574305863 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -54,9 +54,11 @@ const renderComponent = ( const mockSettings = { isTrusted: true, - user: { settings: {} }, - workspace: { settings: {} }, + merged: {}, + user: { settings: {}, originalSettings: {} }, + workspace: { settings: {}, originalSettings: {} }, setValue: vi.fn(), + recomputeMerged: vi.fn(), ...(settingsValue ?? {}), } as unknown as LoadedSettings; @@ -67,6 +69,7 @@ const renderComponent = ( getModel: vi.fn(() => DEFAULT_QWEN_MODEL), setModel: vi.fn().mockResolvedValue(undefined), switchModel: vi.fn().mockResolvedValue(undefined), + getReasoningPreference: vi.fn().mockReturnValue(undefined), getAuthType: vi.fn(() => 'qwen-oauth'), getAllConfiguredModels: vi.fn(() => getFilteredQwenModels().map((m) => ({ @@ -486,34 +489,50 @@ describe('', () => { it('persists model.baseUrl alongside model.name when the selected provider has a baseUrl', async () => { const switchModel = vi.fn().mockResolvedValue(undefined); - const { props, mockSettings } = renderComponent({}, { - getModel: vi.fn(() => 'qwen3.7-max'), - getAuthType: vi.fn(() => AuthType.USE_OPENAI), - switchModel, - getAllConfiguredModels: vi.fn(() => [ - { - id: 'qwen3.7-max', - label: '[Token Plan] qwen3.7-max', - description: '', - authType: AuthType.USE_OPENAI, - baseUrl: 'https://token-plan.example.com/v1', - envKey: 'TOKEN_PLAN_KEY', - }, - { - id: 'qwen3.7-max', - label: '[IdeaLab] qwen3.7-max', - description: '', + const setReasoningEffort = vi.fn(); + const { props, mockSettings } = renderComponent( + {}, + { + getModel: vi.fn(() => 'qwen3.7-max'), + getAuthType: vi.fn(() => AuthType.USE_OPENAI), + switchModel, + setReasoningEffort, + getReasoningPreference: vi.fn(() => 'ultra'), + getModelRouteIdentity: vi + .fn() + .mockReturnValueOnce('qwen3.7-max@token-plan') + .mockReturnValue('qwen3.7-max@idealab'), + getAllConfiguredModels: vi.fn(() => [ + { + id: 'qwen3.7-max', + label: '[Token Plan] qwen3.7-max', + description: '', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://token-plan.example.com/v1', + envKey: 'TOKEN_PLAN_KEY', + }, + { + id: 'qwen3.7-max', + label: '[IdeaLab] qwen3.7-max', + description: '', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://idealab.example.com/v1', + envKey: 'IDEALAB_KEY', + }, + ]), + getContentGeneratorConfig: vi.fn(() => ({ authType: AuthType.USE_OPENAI, + model: 'qwen3.7-max', baseUrl: 'https://idealab.example.com/v1', - envKey: 'IDEALAB_KEY', + })), + } as unknown as Partial, + { + user: { + settings: { model: { reasoningEffort: 'ultra' } }, + originalSettings: { model: { reasoningEffort: 'ultra' } }, }, - ]), - getContentGeneratorConfig: vi.fn(() => ({ - authType: AuthType.USE_OPENAI, - model: 'qwen3.7-max', - baseUrl: 'https://idealab.example.com/v1', - })), - } as unknown as Partial); + } as unknown as Partial, + ); const childOnSelect = mockedSelect.mock.calls[0][0].onSelect; // Select the IdeaLab entry (second provider with the same id). @@ -538,6 +557,14 @@ describe('', () => { 'model.baseUrl', 'https://idealab.example.com/v1', ); + expect(mockSettings.setValue).toHaveBeenCalledWith( + SettingScope.User, + 'model.reasoningEffort', + undefined, + undefined, + { throwOnWriteFailure: true }, + ); + expect(setReasoningEffort).toHaveBeenCalledWith(undefined); expect(props.onClose).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index 2ff084b9995..a3f092ac44e 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -30,6 +30,7 @@ import { ConfigContext } from '../contexts/ConfigContext.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { clearIncompatibleReasoningEffortForModel } from '../../config/reasoning-effort-persistence.js'; import { t } from '../../i18n/index.js'; import { formatUnsupportedVoiceModelMessage, @@ -216,6 +217,7 @@ interface HandleModelSwitchSuccessParams { effectiveBaseUrl: string | undefined; isRuntime: boolean; persistScope?: 'workspace' | 'user'; + previousModelRouteIdentity?: string; } function handleModelSwitchSuccess({ @@ -228,7 +230,15 @@ function handleModelSwitchSuccess({ effectiveBaseUrl, isRuntime, persistScope, + previousModelRouteIdentity, }: HandleModelSwitchSuccessParams): void { + clearIncompatibleReasoningEffortForModel( + config, + settings, + effectiveModelId, + true, + previousModelRouteIdentity, + ); persistModelSelection( settings, effectiveModelId, @@ -954,6 +964,7 @@ export function ModelDialog({ let effectiveAuthType: AuthType | undefined; let effectiveModelId = selected; let isRuntime = false; + let previousModelRouteIdentity: string | undefined; if (!config) { onClose(); @@ -988,6 +999,7 @@ export function ModelDialog({ selectionInFlightRef.current = true; try { + previousModelRouteIdentity = config.getModelRouteIdentity?.(); await config.switchModel(selectedAuthType, modelId, { ...(selectedAuthType !== authType && selectedAuthType === AuthType.QWEN_OAUTH @@ -1043,6 +1055,7 @@ export function ModelDialog({ : (after?.baseUrl ?? selectedEntry?.model.baseUrl), isRuntime, persistScope, + previousModelRouteIdentity, }); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index d64371c2235..546a0a31c4b 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -1348,6 +1348,86 @@ describe('SettingsDialog', () => { }); }); + describe('Reasoning Effort', () => { + const focusReasoningEffort = async (stdin: { + write: (data: string) => void; + }) => { + act(() => stdin.write(TerminalKeys.UP_ARROW as string)); + await wait(); + for (const character of 'reasoning effort') { + act(() => stdin.write(character)); + await wait(); + } + act(() => stdin.write(TerminalKeys.DOWN_ARROW as string)); + await wait(); + }; + + it.each(['ultra', 'Ultra', 'MINIMAL', 'vendor.Custom'])( + 'saves the open string value %s verbatim', + async (effort) => { + vi.mocked(saveModifiedSettings).mockClear(); + const settings = createMockSettings(); + const { stdin, lastFrame, unmount } = render( + + {}} /> + , + ); + + await focusReasoningEffort(stdin); + act(() => stdin.write(TerminalKeys.ENTER as string)); + await wait(); + for (const character of effort) { + act(() => stdin.write(character)); + await wait(); + } + + expect(lastFrame()).toContain(effort); + + act(() => stdin.write(TerminalKeys.ENTER as string)); + await waitFor(() => { + expect(saveModifiedSettings).toHaveBeenCalledWith( + new Set(['model.reasoningEffort']), + { model: { reasoningEffort: effort } }, + settings, + SettingScope.User, + ); + }); + + unmount(); + }, + ); + + it('deletes the setting when the text input contains only whitespace', async () => { + vi.mocked(saveModifiedSettings).mockClear(); + const settings = createMockSettings({ + model: { reasoningEffort: 'ultra' }, + }); + const { stdin, unmount } = render( + + {}} /> + , + ); + + await focusReasoningEffort(stdin); + act(() => stdin.write(TerminalKeys.ENTER as string)); + await wait(); + act(() => stdin.write(' ')); + await wait(); + act(() => stdin.write(TerminalKeys.ENTER as string)); + + await waitFor(() => { + expect(saveModifiedSettings).toHaveBeenCalledWith( + new Set(['model.reasoningEffort']), + {}, + settings, + SettingScope.User, + ); + }); + + unmount(); + }); + }); + describe('Config Tabs and Search', () => { it('renders the tab bar with all three tabs and a search box', () => { const settings = createMockSettings(); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index c5d27179b93..c8620f8735b 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -500,12 +500,14 @@ export function SettingsDialog({ if (key === 'general.outputLanguage') { const trimmed = editBuffer.trim(); parsed = trimmed === '' ? 'auto' : trimmed; + } else if (key === 'model.reasoningEffort') { + parsed = editBuffer.trim() === '' ? undefined : editBuffer; } else { parsed = editBuffer; } } - if (definition) { + if (definition && parsed !== undefined) { const validationError = validateSettingValue(definition, parsed); if (validationError) { setEditingKey(null); diff --git a/packages/cli/src/ui/statusLinePresets.ts b/packages/cli/src/ui/statusLinePresets.ts index 81644b132f8..dcc2b8c27e3 100644 --- a/packages/cli/src/ui/statusLinePresets.ts +++ b/packages/cli/src/ui/statusLinePresets.ts @@ -5,7 +5,6 @@ */ import nodePath from 'node:path'; -import type { ReasoningEffort } from '@qwen-code/qwen-code-core'; import { StreamingState } from './types.js'; export const STATUS_LINE_PRESET_ITEM_IDS = [ @@ -47,7 +46,7 @@ export interface StatusLinePresetConfig { export type StatusLinePresetReasoning = | false | { - effort?: ReasoningEffort; + effort?: string; } | undefined; diff --git a/packages/cli/src/utils/modelConfigUtils.test.ts b/packages/cli/src/utils/modelConfigUtils.test.ts index 4a200f57f93..e3ce868aea3 100644 --- a/packages/cli/src/utils/modelConfigUtils.test.ts +++ b/packages/cli/src/utils/modelConfigUtils.test.ts @@ -181,6 +181,122 @@ describe('modelConfigUtils', () => { } as Settings; } + it.each([ + { + configured: 'none', + expected: false, + expectedPreference: false, + }, + { + configured: 'ultra', + expected: { effort: 'ultra' }, + expectedPreference: 'ultra', + }, + { + configured: 'minimal', + expected: { effort: 'minimal' }, + expectedPreference: 'minimal', + }, + { + configured: 'Vendor.Custom-EFFORT', + expected: { effort: 'Vendor.Custom-EFFORT' }, + expectedPreference: 'Vendor.Custom-EFFORT', + }, + { + configured: ' ', + expected: undefined, + expectedPreference: undefined, + }, + ])( + 'loads the open reasoning effort setting "$configured"', + ({ configured, expected, expectedPreference }) => { + vi.mocked(resolveModelConfig).mockReturnValue({ + config: { model: '' }, + sources: {}, + warnings: [], + }); + + const result = resolveCliGenerationConfig({ + argv: {}, + settings: makeMockSettings({ + model: { name: 'qwen3.8-max', reasoningEffort: configured }, + }), + selectedAuthType: AuthType.USE_OPENAI, + }); + + expect(result.generationConfig.reasoning).toEqual(expected); + expect(result.reasoningPreference).toBe(expectedPreference); + expect(result.reasoningDefault).toBeUndefined(); + expect(result.warnings).toEqual([]); + }, + ); + + it('ignores a persisted disabled preference when thinking is mandatory', () => { + vi.mocked(resolveModelConfig).mockReturnValue({ + config: { + model: 'qwen3.8-max', + thinkingMandatory: true, + reasoning: { effort: 'xhigh' }, + }, + sources: {}, + warnings: [], + }); + + const result = resolveCliGenerationConfig({ + argv: {}, + settings: makeMockSettings({ + model: { name: 'qwen3.8-max', reasoningEffort: 'none' }, + }), + selectedAuthType: AuthType.USE_OPENAI, + }); + + expect(result.generationConfig.reasoning).toEqual({ effort: 'xhigh' }); + expect(result.reasoningPreference).toBe(false); + expect(result.warnings).toContain( + 'Ignoring model.reasoningEffort "none" because the selected model requires thinking.', + ); + }); + + it('keeps a resolved disabled default separate from an unset preference', () => { + vi.mocked(resolveModelConfig).mockReturnValue({ + config: { model: '', reasoning: false }, + sources: {}, + warnings: [], + }); + + const result = resolveCliGenerationConfig({ + argv: {}, + settings: makeMockSettings({ model: { name: 'qwen3.8-max' } }), + selectedAuthType: AuthType.USE_OPENAI, + }); + + expect(result.generationConfig.reasoning).toBe(false); + expect(result.reasoningPreference).toBeUndefined(); + expect(result.reasoningDefault).toBe(false); + }); + + it('applies an explicit opaque preference over a disabled model default', () => { + vi.mocked(resolveModelConfig).mockReturnValue({ + config: { model: '', reasoning: false }, + sources: {}, + warnings: [], + }); + + const result = resolveCliGenerationConfig({ + argv: {}, + settings: makeMockSettings({ + model: { name: 'qwen3.8-max', reasoningEffort: 'Vendor.Ultra' }, + }), + selectedAuthType: AuthType.USE_OPENAI, + }); + + expect(result.generationConfig.reasoning).toEqual({ + effort: 'Vendor.Ultra', + }); + expect(result.reasoningPreference).toBe('Vendor.Ultra'); + expect(result.reasoningDefault).toBe(false); + }); + it('should resolve config from argv with highest precedence', () => { const argv = { model: 'argv-model', diff --git a/packages/cli/src/utils/modelConfigUtils.ts b/packages/cli/src/utils/modelConfigUtils.ts index 2894b43484f..ccc2004999b 100644 --- a/packages/cli/src/utils/modelConfigUtils.ts +++ b/packages/cli/src/utils/modelConfigUtils.ts @@ -9,8 +9,6 @@ import { MODEL_GENERATION_CONFIG_FIELDS, type ContentGeneratorConfig, type ContentGeneratorConfigSources, - normalizeReasoningEffort, - REASONING_EFFORT_TIERS, resolveModelConfig, resolveProviderProtocol, type ModelConfigSourcesInput, @@ -191,6 +189,10 @@ export interface ResolvedCliGenerationConfig { baseUrl: string; /** The full generation config to pass to core Config */ generationConfig: Partial; + /** Explicit global preference, separate from the model/provider default. */ + reasoningPreference?: false | string; + /** Model/provider reasoning before applying the global preference. */ + reasoningDefault?: ContentGeneratorConfig['reasoning']; /** Exact selected modelProviders baseUrl; null selects an implicit route. */ registryBaseUrl?: string | null; /** Source attribution for each resolved field */ @@ -421,25 +423,32 @@ export function resolveCliGenerationConfig( enableOpenAILogging, openAILoggingDir, }; + const reasoningDefault = generationConfig.reasoning; - // Apply the global reasoning-effort preference (settings.model.reasoningEffort, - // set via /effort) onto the unified reasoning config. Skip when thinking is - // explicitly disabled (reasoning === false) so effort never silently - // re-enables it; provider adapters clamp the tier to the active model. + // Apply the global reasoning-effort preference onto the unified reasoning + // config. Effort values are model-defined, so preserve non-blank strings. const rawReasoningEffort = settings.model?.reasoningEffort; - const reasoningEffort = normalizeReasoningEffort(rawReasoningEffort); - // A configured-but-unrecognized value (e.g. a "hihg" typo in settings.json) - // normalizes to undefined and is silently skipped below. Surface it as a - // warning so the user isn't left wondering why /effort had no effect. - const invalidReasoningEffortWarning = - rawReasoningEffort && !reasoningEffort - ? `Ignoring invalid model.reasoningEffort "${rawReasoningEffort}"; expected one of: ${REASONING_EFFORT_TIERS.join(', ')}.` - : undefined; - if (reasoningEffort && generationConfig.reasoning !== false) { + const disabledReasoningUnsupported = + rawReasoningEffort === 'none' && + generationConfig.thinkingMandatory === true; + const ignoredReasoningEffortWarning = disabledReasoningUnsupported + ? 'Ignoring model.reasoningEffort "none" because the selected model requires thinking.' + : undefined; + let reasoningPreference: false | string | undefined; + if (rawReasoningEffort === 'none') { + reasoningPreference = false; + if (!disabledReasoningUnsupported) { + generationConfig.reasoning = false; + } + } else if ( + typeof rawReasoningEffort === 'string' && + rawReasoningEffort.trim() + ) { + reasoningPreference = rawReasoningEffort; generationConfig.reasoning = { - ...(generationConfig.reasoning ?? {}), - effort: reasoningEffort, - }; + ...(generationConfig.reasoning || {}), + effort: rawReasoningEffort, + } as ContentGeneratorConfig['reasoning']; } return { @@ -447,13 +456,15 @@ export function resolveCliGenerationConfig( apiKey: resolved.config.apiKey || '', baseUrl: resolved.config.baseUrl || '', generationConfig, + reasoningPreference, + reasoningDefault, ...(modelProvider ? { registryBaseUrl: modelProvider.baseUrl ?? null } : {}), sources: resolved.sources, warnings: [ ...resolved.warnings, - ...(invalidReasoningEffortWarning ? [invalidReasoningEffortWarning] : []), + ...(ignoredReasoningEffortWarning ? [ignoredReasoningEffortWarning] : []), ...(disambiguationWarning ? [disambiguationWarning] : []), ...(ignoredGenerationConfigWarning ? [ignoredGenerationConfigWarning] diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index c7c8ed41147..b451956f6be 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -5474,6 +5474,8 @@ describe('Server Config (config.ts)', () => { const config = new Config({ ...baseParams, generationConfig: { reasoning: { effort: 'max' } }, + reasoningPreference: 'max', + reasoningDefault: undefined, }); const authType = AuthType.USE_GEMINI; @@ -5495,17 +5497,514 @@ describe('Server Config (config.ts)', () => { }); }); + it('preserves disabled reasoning across an auth refresh that wipes it', async () => { + const config = new Config({ + ...baseParams, + generationConfig: { reasoning: false }, + reasoningPreference: false, + reasoningDefault: undefined, + }); + const authType = AuthType.USE_GEMINI; + + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { + apiKey: 'test-key', + model: 'qwen3-coder-plus', + authType, + } as ContentGeneratorConfig, + sources: {}, + }); + + await config.refreshAuth(authType); + + expect(config.getContentGeneratorConfig().reasoning).toBe(false); + }); + + it('keeps a disabled preference dormant for mandatory models and reapplies it after a hot switch', async () => { + const config = new Config({ + ...baseParams, + generationConfig: { + reasoning: { effort: 'xhigh' }, + thinkingMandatory: true, + }, + reasoningPreference: false, + reasoningDefault: { effort: 'xhigh' }, + }); + const authType = AuthType.QWEN_OAUTH; + const handleHotModelChange = () => + ( + config as unknown as { + handleModelChange: ( + authType: AuthType, + requiresRefresh: boolean, + ) => Promise; + } + ).handleModelChange(authType, false); + + vi.mocked(resolveContentGeneratorConfigWithSources) + .mockReturnValueOnce({ + config: { + apiKey: 'test-key', + model: 'qwen3.8-max', + authType, + reasoning: { effort: 'xhigh' }, + thinkingMandatory: true, + } as ContentGeneratorConfig, + sources: {}, + }) + .mockReturnValueOnce({ + config: { + apiKey: 'test-key', + model: 'qwen3-coder-plus', + authType, + reasoning: { effort: 'high' }, + } as ContentGeneratorConfig, + sources: {}, + }) + .mockReturnValueOnce({ + config: { + apiKey: 'test-key', + model: 'qwen3.8-max', + authType, + reasoning: { effort: 'xhigh' }, + thinkingMandatory: true, + } as ContentGeneratorConfig, + sources: {}, + }); + + await config.refreshAuth(authType); + + expect(config.getReasoningPreference()).toBe(false); + expect(config.getContentGeneratorConfig().reasoning).toEqual({ + effort: 'xhigh', + }); + + await handleHotModelChange(); + + expect(config.getContentGeneratorConfig().thinkingMandatory).toBe( + undefined, + ); + expect(config.getContentGeneratorConfig().reasoning).toBe(false); + + await handleHotModelChange(); + + expect(config.getContentGeneratorConfig().reasoning).toEqual({ + effort: 'xhigh', + }); + config.setReasoningEffort('vendor.ultra'); + expect(config.getReasoningPreference()).toBe('vendor.ultra'); + expect(config.getContentGeneratorConfig().reasoning).toEqual({ + effort: 'vendor.ultra', + }); + }); + + it.each([ + { + label: 'an arbitrary effort', + preference: 'Vendor.Ultra' as const, + expectedReasoning: { effort: 'Vendor.Ultra' }, + }, + { + label: 'disabled reasoning', + preference: false as const, + expectedReasoning: false as const, + }, + ])( + 'keeps $label effective over static qwen3.8 knobs after auth refresh', + async ({ preference, expectedReasoning }) => { + const extraBody = { reasoning_effort: 'high', seed: 7 }; + const samplingParams = { thinking_budget: 4_096, temperature: 0.2 }; + const config = new Config({ + ...baseParams, + generationConfig: { reasoning: expectedReasoning }, + reasoningPreference: preference, + reasoningDefault: undefined, + }); + + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { + apiKey: 'test-key', + model: 'qwen3.8-max', + authType: AuthType.QWEN_OAUTH, + extra_body: extraBody, + samplingParams, + } as ContentGeneratorConfig, + sources: {}, + }); + + await config.refreshAuth(AuthType.QWEN_OAUTH); + + expect(config.getContentGeneratorConfig()).toMatchObject({ + reasoning: expectedReasoning, + extra_body: { seed: 7 }, + samplingParams: { temperature: 0.2 }, + }); + expect(extraBody).toEqual({ reasoning_effort: 'high', seed: 7 }); + expect(samplingParams).toEqual({ + thinking_budget: 4_096, + temperature: 0.2, + }); + }, + ); + + it('round-trips an arbitrary reasoning effort through the public config API', async () => { + const config = new Config(baseParams); + const authType = AuthType.USE_GEMINI; + + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { + apiKey: 'test-key', + model: 'qwen3-coder-plus', + authType, + } as ContentGeneratorConfig, + sources: {}, + }); + + await config.refreshAuth(authType); + config.setReasoningEffort('vendor.ultra'); + + expect(config.getReasoningEffort()).toBe('vendor.ultra'); + }); + + it('restores static qwen3.8 defaults after clearing an explicit effort', async () => { + const authType = AuthType.USE_OPENAI; + const baseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + const extraBody = { enable_thinking: true, seed: 7 }; + const samplingParams = { + reasoning_effort: 'high', + temperature: 0.2, + }; + const config = new Config({ + ...baseParams, + authType, + model: 'qwen3.8-max', + generationConfig: { + baseUrl, + reasoning: { effort: 'xhigh' }, + extra_body: extraBody, + samplingParams, + }, + modelProvidersConfig: { + [authType]: [ + { + id: 'qwen3.8-max', + baseUrl, + generationConfig: { + reasoning: { effort: 'xhigh' }, + extra_body: extraBody, + samplingParams, + }, + }, + ], + }, + reasoningDefault: { effort: 'xhigh' }, + }); + + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { + apiKey: 'test-key', + model: 'qwen3.8-max', + authType, + baseUrl, + reasoning: { effort: 'xhigh' }, + extra_body: extraBody, + samplingParams, + } as ContentGeneratorConfig, + sources: {}, + }); + await config.refreshAuth(authType); + + config.setReasoningEffort('Vendor.Ultra'); + expect(config.getContentGeneratorConfig()).toMatchObject({ + reasoning: { effort: 'Vendor.Ultra' }, + extra_body: { seed: 7 }, + samplingParams: { temperature: 0.2 }, + }); + + config.setReasoningEffort(undefined); + + expect(config.getContentGeneratorConfig()).toMatchObject({ + reasoning: { effort: 'xhigh' }, + extra_body: { enable_thinking: true, seed: 7 }, + samplingParams: { + reasoning_effort: 'high', + temperature: 0.2, + }, + }); + expect(extraBody).toEqual({ enable_thinking: true, seed: 7 }); + expect(samplingParams).toEqual({ + reasoning_effort: 'high', + temperature: 0.2, + }); + }); + + it('clears an explicit preference back to the current model default', async () => { + const authType = AuthType.USE_OPENAI; + const config = new Config({ + ...baseParams, + authType, + model: 'custom-model', + generationConfig: { + apiKey: 'test-key', + baseUrl: 'https://custom.example.com/v1', + reasoning: { effort: 'provider.default' }, + }, + }); + + await config.refreshAuth(authType); + config.setReasoningEffort('session.ultra'); + expect(config.getReasoningPreference()).toBe('session.ultra'); + expect(config.getReasoningEffort()).toBe('session.ultra'); + + config.setReasoningEffort(undefined); + + expect(config.getReasoningPreference()).toBeUndefined(); + expect(config.getReasoningEffort()).toBe('provider.default'); + }); + + it('keeps explicit reasoning disabled until the preference is cleared', async () => { + const authType = AuthType.USE_OPENAI; + const config = new Config({ + ...baseParams, + authType, + model: 'custom-model', + generationConfig: { + apiKey: 'test-key', + baseUrl: 'https://custom.example.com/v1', + reasoning: { effort: 'provider.default' }, + }, + }); + + await config.refreshAuth(authType); + config.disableReasoning(); + expect(config.getReasoningPreference()).toBe(false); + expect(config.getContentGeneratorConfig().reasoning).toBe(false); + + config.setReasoningEffort('high'); + + expect(config.getReasoningPreference()).toBe(false); + expect(config.getContentGeneratorConfig().reasoning).toBe(false); + + config.setReasoningEffort(undefined); + + expect(config.getReasoningPreference()).toBeUndefined(); + expect(config.getReasoningEffort()).toBe('provider.default'); + }); + + it('uses the target model reasoning default when no global override is configured', async () => { + const authType = AuthType.USE_OPENAI; + const baseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + const config = new Config({ + ...baseParams, + authType, + model: 'model-a', + generationConfig: { reasoning: false }, + modelProvidersConfig: { + [authType]: [ + { + id: 'model-a', + baseUrl, + generationConfig: { reasoning: false }, + }, + { + id: 'model-b', + baseUrl, + generationConfig: { + reasoning: { effort: 'provider.ultra' }, + }, + }, + ], + }, + }); + + await config.refreshAuth(authType); + expect(config.getContentGeneratorConfig().reasoning).toBe(false); + + await config.switchModel(authType, 'model-b'); + + expect(config.getContentGeneratorConfig().reasoning).toEqual({ + effort: 'provider.ultra', + }); + }); + + it('clears an override back to the new model default after switching models', async () => { + const authType = AuthType.USE_OPENAI; + const baseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + const config = new Config({ + ...baseParams, + authType, + model: 'model-a', + generationConfig: { reasoning: false }, + modelProvidersConfig: { + [authType]: [ + { + id: 'model-a', + baseUrl, + generationConfig: { reasoning: false }, + }, + { + id: 'model-b', + baseUrl, + generationConfig: { + reasoning: { effort: 'provider.ultra' }, + }, + }, + ], + }, + }); + + await config.refreshAuth(authType); + config.setReasoningEffort('session.ultra'); + await config.switchModel(authType, 'model-b'); + expect(config.getReasoningEffort()).toBe('session.ultra'); + + config.setReasoningEffort(undefined); + + expect(config.getReasoningPreference()).toBeUndefined(); + expect(config.getReasoningEffort()).toBe('provider.ultra'); + }); + + it('preserves an arbitrary reasoning effort across an auth refresh', async () => { + const config = new Config({ + ...baseParams, + generationConfig: { reasoning: { effort: 'vendor.ultra' } }, + reasoningPreference: 'vendor.ultra', + reasoningDefault: undefined, + }); + const authType = AuthType.USE_GEMINI; + + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { + apiKey: 'test-key', + model: 'qwen3-coder-plus', + authType, + } as ContentGeneratorConfig, + sources: {}, + }); + + await config.refreshAuth(authType); + + expect(config.getReasoningEffort()).toBe('vendor.ultra'); + }); + + it.each([ + { + label: 'an arbitrary effort', + reasoning: { effort: 'vendor.ultra' } as const, + }, + { label: 'disabled reasoning', reasoning: false as const }, + ])('preserves $label across a hot model switch', async ({ reasoning }) => { + const config = new Config({ + ...baseParams, + generationConfig: { reasoning }, + reasoningPreference: reasoning === false ? false : reasoning.effort, + reasoningDefault: undefined, + }); + const authType = AuthType.QWEN_OAUTH; + + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { + apiKey: 'test-key', + model: 'qwen3-coder-plus', + authType, + } as ContentGeneratorConfig, + sources: {}, + }); + await config.refreshAuth(authType); + + const modelsConfig = ( + config as unknown as { + modelsConfig: { + getGenerationConfig(): { + reasoning?: ContentGeneratorConfig['reasoning']; + }; + }; + } + ).modelsConfig; + delete modelsConfig.getGenerationConfig().reasoning; + + await ( + config as unknown as { + handleModelChange: ( + authType: AuthType, + requiresRefresh: boolean, + ) => Promise; + } + ).handleModelChange(authType, false); + + expect(config.getContentGeneratorConfig().reasoning).toEqual(reasoning); + expect(modelsConfig.getGenerationConfig().reasoning).toBeUndefined(); + }); + + it.each([ + { + label: 'an arbitrary effort', + reasoning: { effort: 'vendor.ultra' } as const, + }, + { label: 'disabled reasoning', reasoning: false as const }, + ])( + 'preserves $label across a full-refresh model switch', + async ({ reasoning }) => { + const config = new Config({ + ...baseParams, + generationConfig: { reasoning }, + reasoningPreference: reasoning === false ? false : reasoning.effort, + reasoningDefault: undefined, + }); + const authType = AuthType.USE_GEMINI; + + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { + apiKey: 'test-key', + model: 'gemini-a', + authType, + } as ContentGeneratorConfig, + sources: {}, + }); + await config.refreshAuth(authType); + + const genConfig = ( + config as unknown as { + modelsConfig: { + getGenerationConfig(): { + reasoning?: ContentGeneratorConfig['reasoning']; + }; + }; + } + ).modelsConfig.getGenerationConfig(); + delete genConfig.reasoning; + + vi.mocked(resolveContentGeneratorConfigWithSources).mockReturnValue({ + config: { + apiKey: 'test-key', + model: 'gemini-b', + authType, + } as ContentGeneratorConfig, + sources: {}, + }); + + await ( + config as unknown as { + handleModelChange: ( + authType: AuthType, + requiresRefresh: boolean, + ) => Promise; + } + ).handleModelChange(authType, true); + + expect(config.getContentGeneratorConfig().reasoning).toEqual(reasoning); + expect(genConfig.reasoning).toBeUndefined(); + }, + ); + it('re-applies the reasoning effort on a full-refresh model switch that wiped modelsConfig', async () => { - // Regression for the model-switch path: switchModel() runs - // applyResolvedModelDefaults() (which overwrites modelsConfig's - // `reasoning` with the new model's preset) BEFORE onModelChange -> - // handleModelChange fires. So by the time the full-refresh path calls - // refreshAuth, refreshAuth's own capture reads undefined and cannot - // restore the tier. handleModelChange must re-apply it from the live - // contentGeneratorConfig captured before the rebuild. + // A full refresh rebuilds the content-generator config after the model + // switch. The explicit preference must be re-applied after that rebuild. const config = new Config({ ...baseParams, generationConfig: { reasoning: { effort: 'high' } }, + reasoningPreference: 'high', + reasoningDefault: undefined, }); const authType = AuthType.USE_GEMINI; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 22d83257d1b..2418ce66da4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -18,7 +18,6 @@ import type { InputModalities, } from '../core/contentGenerator.js'; import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js'; -import type { ReasoningEffort } from '../core/reasoning-effort.js'; import type { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import type { VisionBridgeModelSelection } from '../services/visionBridge/vision-bridge-service.js'; @@ -1245,6 +1244,10 @@ export interface ConfigParameters { ideMode?: boolean; authType?: AuthType; generationConfig?: Partial; + /** Explicit global/session preference; undefined uses the model default. */ + reasoningPreference?: ReasoningPreference; + /** Model/provider reasoning before the global preference was overlaid. */ + reasoningDefault?: ContentGeneratorConfig['reasoning']; /** Exact initial model registry baseUrl; null selects an implicit route. */ initialModelRegistryBaseUrl?: string | null; /** @@ -1809,6 +1812,16 @@ export type ReasoningEffortOverride = { field: 'enable_thinking' | 'reasoning_effort' | 'thinking_budget'; }; +export type ReasoningPreference = false | string; + +function cloneReasoning( + reasoning: ContentGeneratorConfig['reasoning'], +): ContentGeneratorConfig['reasoning'] { + return reasoning && typeof reasoning === 'object' + ? { ...reasoning } + : reasoning; +} + class SessionWriterShutdownError extends SessionWriterUnavailableError {} function containsErrorByIdentity(error: unknown, candidate: unknown): boolean { @@ -2507,6 +2520,7 @@ export class Config { private visionModel?: string; private compactionModel?: string; private imageModel?: string; + private reasoningPreference: ReasoningPreference | undefined; private readonly visionBridgeTimeoutMs: number | undefined; private readonly modelFallbacks: string[]; private readonly disableAllHooks: boolean; @@ -2852,15 +2866,20 @@ export class Config { // Prefer params.authType over generationConfig.authType because: // - params.authType preserves undefined (user hasn't selected yet) // - generationConfig.authType may have a default value from resolvers + this.reasoningPreference = params.reasoningPreference; + const generationConfig: Partial = { + model: params.model, + ...(params.generationConfig || {}), + baseUrl: params.generationConfig?.baseUrl, + }; + if ('reasoningDefault' in params) { + generationConfig.reasoning = params.reasoningDefault; + } this.modelsConfig = new ModelsConfig({ initialAuthType: params.authType ?? params.generationConfig?.authType, modelProvidersConfig: this.modelProvidersConfig, providerProtocolConfig: this.providerProtocolConfig, - generationConfig: { - model: params.model, - ...(params.generationConfig || {}), - baseUrl: params.generationConfig?.baseUrl, - }, + generationConfig, generationConfigSources: params.generationConfigSources, initialRegistryBaseUrl: params.initialModelRegistryBaseUrl, onModelChange: this.handleModelChange.bind(this), @@ -4206,20 +4225,6 @@ export class Config { * Refresh authentication and rebuild ContentGenerator. */ async refreshAuth(authMethod: AuthType, isInitialAuth?: boolean) { - // The global reasoning effort (settings.model.reasoningEffort, seeded into - // the generation config by the CLI) is NOT a provider field, but - // syncAfterAuthRefresh → applyResolvedModelDefaults overwrites every - // MODEL_GENERATION_CONFIG_FIELDS entry — including `reasoning` — with the - // provider preset's value (undefined for reasoning). Capture the effort - // before the sync wipes it and re-apply it after the config is rebuilt, so - // /effort survives an auth refresh, including the initial one at startup. - // `reasoning` is `false | { effort?, ... } | undefined`; the truthy check - // already excludes both `false` and `undefined`. - const priorReasoning = this.modelsConfig.getGenerationConfig().reasoning; - const priorReasoningEffort = priorReasoning - ? priorReasoning.effort - : undefined; - // Sync modelsConfig state for this auth refresh const modelId = this.modelsConfig.getModel(); this.modelsConfig.syncAfterAuthRefresh(authMethod, modelId); @@ -4250,10 +4255,9 @@ export class Config { // fires — and the resolved model can differ from the pre-auth one. this.publishModelEnv(); - // Re-apply the user's reasoning effort that the provider sync above wiped. - if (priorReasoningEffort) { - this.setReasoningEffort(priorReasoningEffort); - } + // The model config remains the baseline; overlay only the explicit global + // or session preference after the provider has rebuilt it. + this.applyReasoningPreference(); // Initialize BaseLlmClient now that the ContentGenerator is available this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this); @@ -5062,7 +5066,7 @@ export class Config { * config. Returns undefined when thinking is disabled (`reasoning: false`) or * no tier is set (the model/provider default applies). */ - getReasoningEffort(): ReasoningEffort | undefined { + getReasoningEffort(): string | undefined { const reasoning = this.getContentGeneratorConfig()?.reasoning; // `!reasoning` already covers both `false` and `undefined` (both falsy). if (!reasoning) { @@ -5071,6 +5075,11 @@ export class Config { return reasoning.effort; } + /** The explicit global/session preference, excluding model defaults. */ + getReasoningPreference(): ReasoningPreference | undefined { + return this.reasoningPreference; + } + /** * Return a higher-priority static DashScope knob that shadows the current * global effort on qwen3.8-max, so interactive callers can report the @@ -5132,43 +5141,103 @@ export class Config { * Update the reasoning-effort tier at runtime (e.g. `/effort high`). The * request pipeline reads `reasoning.effort` per request, so mutating the live * config in place takes effect on the next turn without an auth refresh. - * Provider adapters clamp the tier to what the active model supports. Pass - * undefined to clear the override and fall back to the model/provider default. + * Provider adapters clamp built-in tiers to what the active model supports + * and interpret provider-specific strings locally. Pass undefined to clear + * the override and fall back to the model/provider default. * - * No-op when thinking is explicitly disabled (`reasoning: false`) so effort - * cannot silently re-enable it. + * No-op when thinking is actively disabled. A mandatory-thinking model may + * replace a dormant disabled preference because it cannot apply there. */ - setReasoningEffort(effort: ReasoningEffort | undefined): void { - const applyEffort = ( - cfg: { reasoning?: ContentGeneratorConfig['reasoning'] } | undefined, - ): void => { - if (!cfg || cfg.reasoning === false) { - return; - } - const next: { effort?: ReasoningEffort; budget_tokens?: number } = { - ...(cfg.reasoning ?? {}), - }; - if (effort) { - next.effort = effort; - } else { - delete next.effort; - } - // Clearing the last key (e.g. setReasoningEffort(undefined) with no - // sibling budget_tokens) collapses `reasoning` back to undefined rather - // than leaving an empty `{}` — an empty object is truthy, so downstream - // `if (cfg.reasoning)` checks would treat reasoning as active and the - // pipeline would emit `reasoning: {}` as wire noise. - cfg.reasoning = Object.keys(next).length > 0 ? next : undefined; - }; - // The main session and a runtime (sub-agent) content generator may hold - // distinct config objects; update whichever the request path reads. - applyEffort(this.contentGeneratorConfig); + setReasoningEffort(effort: string | undefined): void { + if (!effort) { + this.reasoningPreference = undefined; + this.restoreReasoningDefault(); + return; + } + if ( + this.reasoningPreference === false && + this.contentGeneratorConfig?.thinkingMandatory !== true + ) { + return; + } + this.reasoningPreference = effort; + this.applyReasoningPreference(); + } + + disableReasoning(): void { + this.reasoningPreference = false; + this.applyReasoningPreference(); + } + + private forEachReasoningConfig( + callback: (cfg: ContentGeneratorConfig) => void, + ): void { + if (this.contentGeneratorConfig) { + callback(this.contentGeneratorConfig); + } const runtimeCfg = getRuntimeContentGenerator()?.contentGeneratorConfig; if (runtimeCfg && runtimeCfg !== this.contentGeneratorConfig) { - applyEffort(runtimeCfg); + callback(runtimeCfg); + } + } + + private restoreReasoningDefault(): void { + const defaults = this.modelsConfig.getGenerationConfig(); + this.forEachReasoningConfig((cfg) => { + cfg.reasoning = cloneReasoning(defaults.reasoning); + if (!isTieredEffortWireModel(cfg.model)) return; + for (const source of ['extra_body', 'samplingParams'] as const) { + const current = cfg[source]; + const baseline = defaults[source]; + if (!current && !baseline) continue; + const next = { ...(current ?? {}) }; + for (const field of [ + 'enable_thinking', + 'reasoning_effort', + 'thinking_budget', + ] as const) { + if ( + baseline && + Object.prototype.hasOwnProperty.call(baseline, field) + ) { + next[field] = baseline[field]; + } else { + delete next[field]; + } + } + cfg[source] = next; + } + }); + } + + private applyReasoningPreference(): void { + const preference = this.reasoningPreference; + if (preference === undefined) { + return; } - // Keep the rebuildable source in sync so a later refreshAuth keeps the tier. - applyEffort(this.modelsConfig?.getGenerationConfig()); + this.forEachReasoningConfig((cfg) => { + if (preference === false && cfg.thinkingMandatory === true) { + return; + } + if ( + DashScopeOpenAICompatibleProvider.isDashScopeProvider(cfg) && + isTieredEffortWireModel(cfg.model) + ) { + for (const source of ['extra_body', 'samplingParams'] as const) { + const layer = cfg[source]; + if (!layer) continue; + const next = { ...layer }; + delete next['enable_thinking']; + delete next['reasoning_effort']; + delete next['thinking_budget']; + cfg[source] = next; + } + } + cfg.reasoning = + preference === false + ? false + : { ...(cfg.reasoning || {}), effort: preference }; + }); } /** @@ -5331,13 +5400,6 @@ export class Config { return; } - // Reasoning effort is a global, model-independent preference (set via - // /effort). Capture it before the rebuild and re-apply after, so switching - // models never silently drops the user's chosen effort — neither the - // hot-update path (which copies a fixed field set, not `reasoning`) nor the - // full refresh path (which rebuilds the config from scratch). - const priorReasoningEffort = this.getReasoningEffort(); - // Keep full history (including thought parts) on model switch. // Some OpenAI-compatible reasoning models (e.g. DeepSeek) require // reasoning_content to be preserved across turns. @@ -5362,13 +5424,10 @@ export class Config { ); // Hot-update fields (qwen-oauth models share the same auth + client). - // Deliberately does NOT copy `reasoning`: it is a global, model-independent - // preference captured in `priorReasoningEffort` above and re-applied via - // setReasoningEffort() below. Do not add `reasoning` here — that would - // overwrite the live tier with the new model's default and make the - // restore a no-op. this.contentGeneratorConfig.model = config.model; this.contentGeneratorConfig.samplingParams = config.samplingParams; + this.contentGeneratorConfig.reasoning = cloneReasoning(config.reasoning); + this.contentGeneratorConfig.thinkingMandatory = config.thinkingMandatory; this.contentGeneratorConfig.contextWindowSize = config.contextWindowSize; this.contentGeneratorConfig.enableCacheControl = config.enableCacheControl; @@ -5424,26 +5483,12 @@ export class Config { this.contentGeneratorConfigSources['toolResultContentFormat'] = sources['toolResultContentFormat']; } - if (priorReasoningEffort) { - this.setReasoningEffort(priorReasoningEffort); - } + this.applyReasoningPreference(); resetPreloadedContentGenerator(this.contentGenerator); return; } - // Full refresh path. `refreshAuth` re-applies the reasoning effort it - // captures, but on a model *switch* that capture is already stale: the - // preceding switchModel() ran applyResolvedModelDefaults(), which overwrote - // modelsConfig's `reasoning` with the new model's preset (undefined for most - // models), BEFORE this callback fires. So refreshAuth reads `undefined` and - // cannot restore the tier. Re-apply here from `priorReasoningEffort`, which - // we captured off the still-intact live contentGeneratorConfig above. This is - // a no-op when the new model disables thinking (`reasoning: false`), since - // setReasoningEffort() skips that case and never silently re-enables it. await this.refreshAuth(authType); - if (priorReasoningEffort) { - this.setReasoningEffort(priorReasoningEffort); - } } /** diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts index b792f66dddf..7fcb22c0c30 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.test.ts @@ -1778,6 +1778,56 @@ describe('AnthropicContentGenerator', () => { ); }); + it.each([ + { + label: 'Anthropic', + model: 'claude-opus-4-8', + baseUrl: 'https://api.anthropic.com', + }, + { + label: 'DeepSeek anthropic-compatible', + model: 'deepseek-v4-pro', + baseUrl: 'https://api.deepseek.com/anthropic', + }, + ])( + 'passes a provider-specific effort through on $label', + async ({ model, baseUrl }) => { + const { AnthropicContentGenerator } = await importGenerator(); + anthropicState.createImpl.mockResolvedValue({ + id: 'anthropic-1', + model, + content: [{ type: 'text', text: 'hi' }], + }); + + const generator = new AnthropicContentGenerator( + { + model, + apiKey: 'test-key', + baseUrl, + timeout: 10_000, + maxRetries: 2, + samplingParams: { max_tokens: 500 }, + schemaCompliance: 'auto', + reasoning: { effort: 'vendor.ultra' }, + }, + mockConfig, + ); + + await generator.generateContent({ + model: 'models/ignored', + contents: 'Hello', + } as unknown as GenerateContentParameters); + + const [anthropicRequest] = + anthropicState.lastCreateArgs as AnthropicCreateArgs; + expect(anthropicRequest).toEqual( + expect.objectContaining({ + output_config: { effort: 'vendor.ultra' }, + }), + ); + }, + ); + // Claude 4.8+ deprecated the `temperature` sampling parameter — the // server responds with a 400 when it is sent. Verify the generator // omits it for 4.8+ and keeps it for older models. diff --git a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts index b9a07a0fd3c..271cb2573eb 100644 --- a/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts +++ b/packages/core/src/core/anthropicContentGenerator/anthropicContentGenerator.ts @@ -20,6 +20,7 @@ import type { } from '../contentGenerator.js'; import { clampReasoningEffort, + isBuiltInReasoningEffort, type ReasoningEffort, } from '../reasoning-effort.js'; type Message = Anthropic.Message; @@ -287,9 +288,10 @@ type MessageCreateParamsWithThinking = MessageCreateParamsNonStreaming & { thinking?: AnthropicThinkingParam; // Anthropic beta feature: output_config.effort (requires beta header // effort-2025-11-24), not yet represented in the official SDK types we depend - // on. Accepts the full ladder; xhigh/max are gated per model via - // anthropicSupportedEffortTiers + clampReasoningEffort. - output_config?: { effort: ReasoningEffort }; + // on. Built-in tiers are gated per model via + // anthropicSupportedEffortTiers + clampReasoningEffort; provider-specific + // strings pass through verbatim. + output_config?: { effort: string }; }; export class AnthropicContentGenerator implements ContentGenerator { @@ -684,10 +686,9 @@ export class AnthropicContentGenerator implements ContentGenerator { request: GenerateContentParameters, ): Promise { const sampling = this.buildSamplingParameters(request); - // Normalize reasoning.effort once per request (clamps DeepSeek-only - // 'max' to 'high' for stricter Anthropic backends and logs the - // downgrade once). Both the thinking budget ladder and output_config - // consume the result so the wire shape stays internally consistent. + // Resolve reasoning.effort once per request. Built-in tiers are clamped to + // the active endpoint's declared ladder; provider-specific strings remain + // verbatim. Both thinking and output_config consume the same result. const effectiveEffort = this.resolveEffectiveEffort(request); const thinking = this.buildThinkingConfig(request, effectiveEffort); const outputConfig = this.buildOutputConfig(request, effectiveEffort); @@ -902,7 +903,7 @@ export class AnthropicContentGenerator implements ContentGenerator { */ private resolveEffectiveEffort( request: GenerateContentParameters, - ): ReasoningEffort | undefined { + ): string | undefined { if (request.config?.thinkingConfig?.includeThoughts === false) { return undefined; } @@ -914,6 +915,9 @@ export class AnthropicContentGenerator implements ContentGenerator { if (effort === undefined) { return undefined; } + if (!isBuiltInReasoningEffort(effort)) { + return effort; + } if (isDeepSeekAnthropicHostname(this.contentGeneratorConfig)) { // DeepSeek's anthropic-compatible output_config.effort accepts only // high/max. Mirror the DeepSeek OpenAI adapter (deepseek.ts): low/medium @@ -1009,7 +1013,7 @@ export class AnthropicContentGenerator implements ContentGenerator { private buildThinkingConfig( request: GenerateContentParameters, - effectiveEffort: ReasoningEffort | undefined, + effectiveEffort: string | undefined, ): AnthropicThinkingParam | undefined { if (request.config?.thinkingConfig?.includeThoughts === false) { return undefined; @@ -1110,14 +1114,14 @@ export class AnthropicContentGenerator implements ContentGenerator { private buildOutputConfig( request: GenerateContentParameters, - effectiveEffort: ReasoningEffort | undefined, - ): { effort: ReasoningEffort } | undefined { + effectiveEffort: string | undefined, + ): { effort: string } | undefined { // resolveEffectiveEffort already returns undefined when: // - per-request includeThoughts is false (side queries) // - reasoning is disabled or unset // - the user didn't set an effort - // and clamps the tier to what the current model supports. Just consume the - // value here. + // Built-in tiers have already been clamped; provider-specific values are + // unchanged. Just consume the value here. if (effectiveEffort === undefined) return undefined; return { effort: effectiveEffort }; } diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 1bd2191b67e..4036723cd7f 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -28,7 +28,6 @@ import { } from '../models/modelConfigErrors.js'; import { PROVIDER_SOURCED_FIELDS } from '../models/constants.js'; import { preloadRuntimeFetchModule } from '../utils/runtimeFetchOptions.js'; -import type { ReasoningEffort } from './reasoning-effort.js'; /** * Interface abstracting the core content generation functionality. @@ -132,9 +131,9 @@ export type ContentGeneratorConfig = { reasoning?: | false | { - // Unified reasoning-effort ladder (see core/reasoning-effort.ts). - // Providers accept different subsets and use different wire fields; - // each provider adapter maps + clamps this tier onto the active model: + // The built-in picker exposes the unified five-tier ladder from + // core/reasoning-effort.ts. Provider-specific values also pass through + // this runtime boundary and are interpreted by the active adapter: // - 'xhigh'/'max' are extra-strong tiers (DeepSeek `reasoning_effort`, // Anthropic `output_config.effort` on Opus 4.7+, OpenAI `xhigh`). // - Generic OpenAI-compatible endpoints and the DashScope @@ -146,7 +145,7 @@ export type ContentGeneratorConfig = { // debugLogger.warn, when the baseURL doesn't look like a // DeepSeek-compatible endpoint, so configs targeting DeepSeek don't // 400 when the same auth profile is reused against api.anthropic.com. - effort?: ReasoningEffort; + effort?: string; budget_tokens?: number; }; proxy?: string | undefined; diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts index 50d0ca09860..9d5666b4b96 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.test.ts @@ -353,6 +353,167 @@ describe('LlmContentGenerator', () => { ); }); + it("maps provider-specific reasoning effort 'minimal' to MINIMAL", async () => { + const generatorWithMinimal = new LlmContentGenerator( + { apiKey: 'test' }, + { + model: 'gemini-2.5-pro', + reasoning: { effort: 'minimal' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ); + + await generatorWithMinimal.generateContent( + { model: 'gemini-2.5-pro', contents: [] }, + 'prompt-id', + ); + + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + thinkingConfig: { + includeThoughts: true, + thinkingLevel: 'MINIMAL', + }, + }), + }), + ); + }); + + it('rejects an unsupported provider-specific reasoning effort', async () => { + const generatorWithUnknown = new LlmContentGenerator( + { apiKey: 'test' }, + { + model: 'gemini-2.5-pro', + reasoning: { effort: 'vendor.ultra' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ); + + await expect( + generatorWithUnknown.generateContent( + { model: 'gemini-2.5-pro', contents: [] }, + 'prompt-id', + ), + ).rejects.toThrow('Unsupported Gemini reasoning effort: "vendor.ultra"'); + expect(mockGoogleGenAI.models.generateContent).not.toHaveBeenCalled(); + }); + + it('honors a request reasoning opt-out before validating the configured effort', async () => { + const generatorWithUnknown = new LlmContentGenerator( + { apiKey: 'test' }, + { + model: 'gemini-2.5-pro', + reasoning: { effort: 'vendor.ultra' }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ); + + await generatorWithUnknown.generateContent( + { + model: 'gemini-2.5-pro', + contents: [], + config: { thinkingConfig: { includeThoughts: false } }, + }, + 'prompt-id', + ); + + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledOnce(); + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + thinkingConfig: { includeThoughts: false }, + }), + }), + ); + }); + + it('keeps validating configured effort when mandatory thinking overrides a request opt-out', async () => { + const generatorWithUnknown = new LlmContentGenerator( + { apiKey: 'test' }, + { + model: 'gemini-2.5-pro', + reasoning: { effort: 'vendor.ultra' }, + thinkingMandatory: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ); + + await expect( + generatorWithUnknown.generateContent( + { + model: 'gemini-2.5-pro', + contents: [], + config: { thinkingConfig: { includeThoughts: false } }, + }, + 'prompt-id', + ), + ).rejects.toThrow('Unsupported Gemini reasoning effort: "vendor.ultra"'); + expect(mockGoogleGenAI.models.generateContent).not.toHaveBeenCalled(); + }); + + it('keeps mandatory thinking enabled across request and config opt-outs', async () => { + const generatorWithMandatoryThinking = new LlmContentGenerator( + { apiKey: 'test' }, + { + model: 'gemini-2.5-pro', + reasoning: false, + thinkingMandatory: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ); + + await generatorWithMandatoryThinking.generateContent( + { + model: 'gemini-2.5-pro', + contents: [], + config: { thinkingConfig: { includeThoughts: false } }, + }, + 'prompt-id', + ); + + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + thinkingConfig: { + includeThoughts: true, + thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED', + }, + }), + }), + ); + }); + + it('does not inherit mandatory thinking through a request model override', async () => { + const generatorWithMandatoryThinking = new LlmContentGenerator( + { apiKey: 'test' }, + { + model: 'gemini-2.5-pro', + reasoning: { effort: 'vendor.ultra' }, + thinkingMandatory: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ); + + await generatorWithMandatoryThinking.generateContent( + { + model: 'gemini-2.0-flash', + contents: [], + config: { thinkingConfig: { includeThoughts: false } }, + }, + 'prompt-id', + ); + + expect(mockGoogleGenAI.models.generateContent).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gemini-2.0-flash', + config: expect.objectContaining({ + thinkingConfig: { includeThoughts: false }, + }), + }), + ); + }); + it('should strip displayName from inlineData and fileData before sending to API', async () => { const request = { model: 'gemini-1.5-flash', diff --git a/packages/core/src/core/llm-content-generator/llm-content-generator.ts b/packages/core/src/core/llm-content-generator/llm-content-generator.ts index c23f72cc355..b4fc4bb62ac 100644 --- a/packages/core/src/core/llm-content-generator/llm-content-generator.ts +++ b/packages/core/src/core/llm-content-generator/llm-content-generator.ts @@ -101,6 +101,12 @@ export class LlmContentGenerator implements ContentGenerator { ): GenerateContentConfig { const configSamplingParams = this.contentGeneratorConfig?.samplingParams; const requestConfig = request.config || {}; + const requestModel = + request.model || this.contentGeneratorConfig?.model || ''; + const thinkingMandatory = + this.contentGeneratorConfig?.thinkingMandatory === true && + requestModel.toLowerCase() === + (this.contentGeneratorConfig.model ?? '').toLowerCase(); // Helper function to get parameter value with priority: config > request > default const getParameterValue = ( @@ -140,32 +146,39 @@ export class LlmContentGenerator implements ContentGenerator { configSamplingParams?.frequency_penalty, 'frequencyPenalty', ), - thinkingConfig: getParameterValue( - this.buildThinkingConfig(), - 'thinkingConfig', - { - includeThoughts: true, - thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED' as ThinkingLevel, - }, - ), + thinkingConfig: + requestConfig.thinkingConfig?.includeThoughts === false && + !thinkingMandatory + ? requestConfig.thinkingConfig + : getParameterValue( + this.buildThinkingConfig(thinkingMandatory), + 'thinkingConfig', + { + includeThoughts: true, + thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED' as ThinkingLevel, + }, + ), }; } - private buildThinkingConfig(): - | { includeThoughts: boolean; thinkingLevel?: ThinkingLevel } - | undefined { + private buildThinkingConfig( + thinkingMandatory: boolean, + ): { includeThoughts: boolean; thinkingLevel?: ThinkingLevel } | undefined { const reasoning = this.contentGeneratorConfig?.reasoning; - if (reasoning === false) { + if (reasoning === false && !thinkingMandatory) { return { includeThoughts: false }; } if (reasoning) { - // Gemini's thinkingLevel ladder is MINIMAL / LOW / MEDIUM / HIGH — there - // is no xhigh/max, so the extra-strong tiers are capped at HIGH. An unset - // effort stays UNSPECIFIED so the model picks its own default. + // Gemini's thinkingLevel ladder is MINIMAL / LOW / MEDIUM / HIGH. The + // provider-specific `minimal` value maps directly; built-in xhigh/max are + // capped at HIGH. An unset effort lets the model pick its own default. let thinkingLevel: ThinkingLevel; switch (reasoning.effort) { + case 'minimal': + thinkingLevel = 'MINIMAL' as ThinkingLevel; + break; case 'low': thinkingLevel = 'LOW' as ThinkingLevel; break; @@ -192,17 +205,10 @@ export class LlmContentGenerator implements ContentGenerator { // No effort set — let the model pick its own default. thinkingLevel = 'THINKING_LEVEL_UNSPECIFIED' as ThinkingLevel; break; - default: { - // Exhaustiveness guard: every ReasoningEffort tier (and undefined) is - // handled above, so this is unreachable. Adding a new tier without a - // matching case makes this a TypeScript compile error rather than a - // silent fall-through to UNSPECIFIED. (A `default` is required here by - // the eslint default-case rule.) - const _exhaustive: never = reasoning.effort; - void _exhaustive; - thinkingLevel = 'THINKING_LEVEL_UNSPECIFIED' as ThinkingLevel; - break; - } + default: + throw new Error( + `Unsupported Gemini reasoning effort: ${JSON.stringify(reasoning.effort)}`, + ); } return { @@ -211,6 +217,13 @@ export class LlmContentGenerator implements ContentGenerator { }; } + if (thinkingMandatory) { + return { + includeThoughts: true, + thinkingLevel: 'THINKING_LEVEL_UNSPECIFIED' as ThinkingLevel, + }; + } + return undefined; } diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index a46a564fb77..0c54a9624f7 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -1074,6 +1074,154 @@ describe('ContentGenerationPipeline', () => { expect(apiCall.tool_choice).toBe('required'); }); + it('honors a request reasoning opt-out before validating the configured qwen effort', async () => { + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.5-flash', + authType: AuthType.QWEN_OAUTH, + reasoning: { effort: 'vendor.ultra' }, + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const realProvider = new DashScopeOpenAICompatibleProvider( + mockContentGeneratorConfig, + { + getContentGeneratorConfig: () => ({ enableCacheControl: false }), + } as unknown as Config, + ); + (mockProvider.buildRequest as Mock).mockImplementation( + (req, userPromptId, options) => + realProvider.buildRequest(req, userPromptId, options), + ); + + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Classify action' }, + ]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute( + { + model: 'qwen3.5-flash', + contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }, + 'side-query:reasoning-opt-out', + ); + + expect(mockClient.chat.completions.create).toHaveBeenCalledOnce(); + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBe(false); + expect(apiCall.reasoning).toBeUndefined(); + expect(apiCall.reasoning_effort).toBeUndefined(); + }); + + it('preserves the configured effort when mandatory qwen thinking overrides a request opt-out', async () => { + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + model: 'qwen3.8-max-preview', + authType: AuthType.QWEN_OAUTH, + reasoning: { effort: 'vendor.ultra' }, + thinkingMandatory: true, + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + const realProvider = new DashScopeOpenAICompatibleProvider( + mockContentGeneratorConfig, + { + getContentGeneratorConfig: () => ({ enableCacheControl: false }), + } as unknown as Config, + ); + (mockProvider.buildRequest as Mock).mockImplementation( + (req, userPromptId, options) => + realProvider.buildRequest(req, userPromptId, options), + ); + + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Classify action' }, + ]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute( + { + model: 'qwen3.8-max-preview', + contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }, + 'side-query:mandatory-reasoning', + ); + + expect(mockClient.chat.completions.create).toHaveBeenCalledOnce(); + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.enable_thinking).toBeUndefined(); + expect(apiCall.reasoning_effort).toBe('vendor.ultra'); + expect(apiCall.reasoning).toBeUndefined(); + }); + + it('preserves the configured effort for a mandatory model on a generic endpoint', async () => { + mockContentGeneratorConfig = { + ...mockContentGeneratorConfig, + baseUrl: 'https://api.moonshot.ai/v1', + model: 'kimi-k3', + reasoning: { effort: 'vendor.ultra' }, + thinkingMandatory: true, + samplingParams: undefined, + } as ContentGeneratorConfig; + mockConfig = { + ...mockConfig, + contentGeneratorConfig: mockContentGeneratorConfig, + }; + pipeline = new ContentGenerationPipeline(mockConfig); + + (mockConverter.convertLlmRequestToOpenAI as Mock).mockReturnValue([ + { role: 'user', content: 'Classify action' }, + ]); + (mockConverter.convertOpenAIResponseToLlm as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'r', + choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }], + } as OpenAI.Chat.ChatCompletion); + + await pipeline.execute( + { + model: 'kimi-k3', + contents: [{ parts: [{ text: 'Classify action' }], role: 'user' }], + config: { thinkingConfig: { includeThoughts: false } }, + }, + 'side-query:mandatory-generic-reasoning', + ); + + expect(mockClient.chat.completions.create).toHaveBeenCalledOnce(); + const apiCall = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(apiCall.reasoning).toEqual({ effort: 'vendor.ultra' }); + }); + it('never ships the max tier to the tiered DashScope family end to end', async () => { // `/effort max` writes the tier into config, so a raw pass-through // 400s on this request and on every later one in the session. Drive @@ -4956,6 +5104,7 @@ describe('ContentGenerationPipeline', () => { response_format: { type: 'json_object' }, }), userPromptId, + { reasoningDisabled: false }, ); expect(mockClient.chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -5012,6 +5161,7 @@ describe('ContentGenerationPipeline', () => { }, }), userPromptId, + { reasoningDisabled: false }, ); }); @@ -5061,6 +5211,7 @@ describe('ContentGenerationPipeline', () => { }, }), 'test-prompt-id', + { reasoningDisabled: false }, ); }); @@ -5109,6 +5260,7 @@ describe('ContentGenerationPipeline', () => { }, }), 'test-prompt-id', + { reasoningDisabled: false }, ); }); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 63ab4db3c6e..ef08271250c 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -1101,23 +1101,6 @@ export class ContentGenerationPipeline { } } - // Let provider enhance the request (e.g., add metadata, cache control) - let providerRequest = this.config.provider.buildRequest( - baseRequest, - userPromptId, - ); - if ( - this.contentGeneratorConfig.enableCacheControl !== false && - isOfficialOpenAIEndpoint(this.contentGeneratorConfig) - ) { - providerRequest = applyOfficialOpenAIPromptCaching( - providerRequest, - this.config.cliConfig.getSessionId?.(), - request.promptCacheSharing === true, - isInForkExecution() ? undefined : (getCurrentAgentId() ?? undefined), - ); - } - // Reasoning is disabled when either: // - the per-request opt-out is set (forked queries for suggestions), // - the config-level opt-out is set (`reasoning: false`). @@ -1140,7 +1123,29 @@ export class ContentGenerationPipeline { const reasoningDisabled = request.config?.thinkingConfig?.includeThoughts === false || this.contentGeneratorConfig.reasoning === false; - if (reasoningDisabled) { + const effectiveReasoningDisabled = reasoningDisabled && !thinkingMandatory; + + // Let provider enhance the request (e.g., add metadata, cache control). + // Providers that translate configured effort need the effective opt-out + // before validating a knob that will not reach the wire. + let providerRequest = this.config.provider.buildRequest( + baseRequest, + userPromptId, + { reasoningDisabled: effectiveReasoningDisabled }, + ); + if ( + this.contentGeneratorConfig.enableCacheControl !== false && + isOfficialOpenAIEndpoint(this.contentGeneratorConfig) + ) { + providerRequest = applyOfficialOpenAIPromptCaching( + providerRequest, + this.config.cliConfig.getSessionId?.(), + request.promptCacheSharing === true, + isInForkExecution() ? undefined : (getCurrentAgentId() ?? undefined), + ); + } + + if (effectiveReasoningDisabled) { const typed = providerRequest as unknown as Record; // Provider buildRequest doesn't auto-inject `enable_thinking`, so a // guarded `in typed` check would never fire for default qwen3 configs. @@ -1457,7 +1462,11 @@ export class ContentGenerationPipeline { // configured reasoning object when explicitly enabled. This keeps provider- and // model-specific semantics intact while honoring request-level opt-out. - if (request.config?.thinkingConfig?.includeThoughts === false) { + const model = request.model || this.contentGeneratorConfig.model || ''; + if ( + request.config?.thinkingConfig?.includeThoughts === false && + !this.requiresThinking(model) + ) { return {}; } diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts index 8c24da48900..20477b0db86 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.test.ts @@ -697,6 +697,22 @@ describe('DashScopeOpenAICompatibleProvider', () => { expect(result['enable_thinking']).toBe(true); }); + it('rejects a provider-specific effort on a toggle-only qwen model', () => { + const generator = new DashScopeOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + reasoning: { effort: 'vendor.ultra' }, + } as ContentGeneratorConfig, + mockCliConfig, + ); + + expect(() => + generator.buildRequest({ ...baseRequest }, 'test-prompt-id'), + ).toThrow( + 'Unsupported reasoning effort "vendor.ultra" for toggle-only Qwen model "qwen-max". Change or clear the active reasoning effort (model.reasoningEffort when persisted) before using this model.', + ); + }); + describe.each(['qwen3.8-max', 'qwen3.8-max-preview'])( '%s reasoning effort', (model) => { @@ -727,6 +743,33 @@ describe('DashScopeOpenAICompatibleProvider', () => { expect(result['reasoning']).toBeUndefined(); }, ); + + it.each(['minimal', 'ultra', 'vendor.custom'])( + 'passes provider-specific value %s through verbatim', + (effort) => { + const generator = new DashScopeOpenAICompatibleProvider( + { + ...mockContentGeneratorConfig, + model, + reasoning: { effort }, + } as ContentGeneratorConfig, + mockCliConfig, + ); + + const result = generator.buildRequest( + { + ...baseRequest, + model, + reasoning: { effort }, + } as unknown as Parameters[0], + 'test-prompt-id', + ) as unknown as Record; + + expect(result['reasoning_effort']).toBe(effort); + expect(result['enable_thinking']).toBeUndefined(); + expect(result['reasoning']).toBeUndefined(); + }, + ); }, ); diff --git a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts index a3da0cd4e9a..8d3d3397388 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/dashscope.ts @@ -14,6 +14,7 @@ import type { ChatCompletionContentPartTextWithCache, ChatCompletionContentPartWithCache, ChatCompletionToolWithCache, + OpenAIProviderRequestOptions, } from './types.js'; import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js'; import { createDebugLogger } from '../../../utils/debugLogger.js'; @@ -22,7 +23,10 @@ import { isTieredEffortWireModel, } from '../../modalityDefaults.js'; import type { ReasoningEffort } from '../../reasoning-effort.js'; -import { clampReasoningEffort } from '../../reasoning-effort.js'; +import { + clampReasoningEffort, + isBuiltInReasoningEffort, +} from '../../reasoning-effort.js'; import { DefaultOpenAICompatibleProvider } from './default.js'; const debugLogger = createDebugLogger('DashScopeOpenAICompatibleProvider'); @@ -341,6 +345,7 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr override buildRequest( request: OpenAI.Chat.ChatCompletionCreateParams, userPromptId: string, + options?: OpenAIProviderRequestOptions, ): OpenAI.Chat.ChatCompletionCreateParams { let messages = request.messages; let tools = request.tools; @@ -384,9 +389,11 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr // qwen3.8-max accepts the unified effort tiers directly. Older qwen hybrid // models still expose only the on/off `enable_thinking` switch. User - // extra_body wins (merged last); the disable path (reasoning: false) is - // handled upstream in the pipeline. - const qwenEffortConfig = this.buildQwenEffortConfig(request.model); + // extra_body wins (merged last); explicit reasoning opt-outs are handled + // upstream in the pipeline. + const qwenEffortConfig = options?.reasoningDisabled + ? {} + : this.buildQwenEffortConfig(request.model); const rawRequestParams = requestWithTokenLimits as unknown as Record< string, unknown @@ -549,6 +556,11 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr return { reasoning_effort: this.clampTieredEffort(reasoning.effort) }; } if (isQwenFamilyWireModel(wireModel)) { + if (!isBuiltInReasoningEffort(reasoning.effort)) { + throw new Error( + `Unsupported reasoning effort ${JSON.stringify(reasoning.effort)} for toggle-only Qwen model ${JSON.stringify(wireModel)}. Change or clear the active reasoning effort (model.reasoningEffort when persisted) before using this model.`, + ); + } return { enable_thinking: true }; } return {}; @@ -563,7 +575,10 @@ export class DashScopeOpenAICompatibleProvider extends DefaultOpenAICompatiblePr * `extra_body` / `samplingParams` `reasoning_effort` is a documented * verbatim override and is merged after this, so it still ships unchanged. */ - private clampTieredEffort(effort: ReasoningEffort): ReasoningEffort { + private clampTieredEffort(effort: string): string { + if (!isBuiltInReasoningEffort(effort)) { + return effort; + } const clamped = clampReasoningEffort(effort, DASHSCOPE_TIERED_EFFORTS); if (clamped !== effort && !this.effortClampWarned) { debugLogger.warn( diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 76eb7a839ec..f66f12a3f04 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -14,8 +14,8 @@ import { } from '../../tokenLimits.js'; import type { ReasoningEffort } from '../../reasoning-effort.js'; import { - REASONING_EFFORT_TIERS, clampReasoningEffort, + isBuiltInReasoningEffort, } from '../../reasoning-effort.js'; import { createDebugLogger } from '../../../utils/debugLogger.js'; @@ -165,14 +165,11 @@ export class DefaultOpenAICompatibleProvider const loose = request as unknown as Record; const reasoning = loose['reasoning'] as { effort?: unknown } | undefined; const effort = reasoning?.effort; - if ( - typeof effort !== 'string' || - !REASONING_EFFORT_TIERS.includes(effort as ReasoningEffort) - ) { + if (!isBuiltInReasoningEffort(effort)) { return request; } const clamped = clampReasoningEffort( - effort as ReasoningEffort, + effort, this.supportedReasoningEffortsFor( (loose['model'] as string | undefined) ?? this.contentGeneratorConfig.model, diff --git a/packages/core/src/core/openaiContentGenerator/provider/types.ts b/packages/core/src/core/openaiContentGenerator/provider/types.ts index f70f9fcd13f..ffacaf53224 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/types.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/types.ts @@ -8,6 +8,10 @@ export type OpenAIRequestContextOverrides = { toolResultContentFormat?: ContentGeneratorConfig['toolResultContentFormat']; }; +export type OpenAIProviderRequestOptions = { + reasoningDisabled: boolean; +}; + // Extended types to support cache_control for DashScope export interface ChatCompletionContentPartTextWithCache extends OpenAI.Chat.ChatCompletionContentPartText { @@ -29,6 +33,7 @@ export interface OpenAICompatibleProvider { buildRequest( request: OpenAI.Chat.ChatCompletionCreateParams, userPromptId: string, + options?: OpenAIProviderRequestOptions, ): OpenAI.Chat.ChatCompletionCreateParams; getDefaultGenerationConfig(): GenerateContentConfig; getResponseParsingOptions?(): OpenAIResponseParsingOptions; diff --git a/packages/core/src/core/reasoning-effort.test.ts b/packages/core/src/core/reasoning-effort.test.ts index 3f820146785..a41f3bf8081 100644 --- a/packages/core/src/core/reasoning-effort.test.ts +++ b/packages/core/src/core/reasoning-effort.test.ts @@ -10,6 +10,7 @@ import { REASONING_EFFORT_TIERS, applyReasoningEffort, clampReasoningEffort, + isBuiltInReasoningEffort, normalizeReasoningEffort, type ReasoningEffort, } from './reasoning-effort.js'; @@ -26,6 +27,19 @@ describe('REASONING_EFFORT_TIERS', () => { }); }); +describe('isBuiltInReasoningEffort', () => { + it('accepts only the five tiers exposed by the built-in picker', () => { + for (const tier of REASONING_EFFORT_TIERS) { + expect(isBuiltInReasoningEffort(tier)).toBe(true); + } + expect(isBuiltInReasoningEffort('minimal')).toBe(false); + expect(isBuiltInReasoningEffort('ultra')).toBe(false); + expect(isBuiltInReasoningEffort('vendor.custom')).toBe(false); + expect(isBuiltInReasoningEffort('none')).toBe(false); + expect(isBuiltInReasoningEffort(undefined)).toBe(false); + }); +}); + describe('normalizeReasoningEffort', () => { it('accepts canonical tiers case-insensitively', () => { expect(normalizeReasoningEffort('LOW')).toBe('low'); diff --git a/packages/core/src/core/reasoning-effort.ts b/packages/core/src/core/reasoning-effort.ts index aac10131782..54d850045bf 100644 --- a/packages/core/src/core/reasoning-effort.ts +++ b/packages/core/src/core/reasoning-effort.ts @@ -27,6 +27,15 @@ export const REASONING_EFFORT_TIERS: readonly ReasoningEffort[] = [ 'max', ] as const; +export function isBuiltInReasoningEffort( + value: unknown, +): value is ReasoningEffort { + return ( + typeof value === 'string' && + REASONING_EFFORT_TIERS.some((tier) => tier === value) + ); +} + /** * Numeric strength used when clamping a requested tier down to what a model * supports. Gaps are intentional so future intermediate tiers (e.g. a diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 31376362f73..7b1757394ae 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -67,6 +67,11 @@ export { runOutsideAgentContext, } from './agents/runtime/agent-context.js'; export * from './core/reasoning-effort.js'; +/** @internal */ +export { + isQwenFamilyWireModel, + isTieredEffortWireModel, +} from './core/modalityDefaults.js'; export * from './core/coreToolScheduler.js'; export * from './core/permissionFlow.js'; export * from './core/permission-helpers.js'; diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 01143665a48..134a08b3ac5 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -236,6 +236,7 @@ export type { export { DAEMON_APPROVAL_MODES, DAEMON_ERROR_KINDS, + REASONING_EFFORT_PERSISTENCE_ERROR_KIND, DaemonCapabilityMissingError, isDaemonContentHash, requireWorkspaceCwd, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index d910f04ed76..df80eb30050 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -1645,6 +1645,10 @@ export type DaemonStatus = | 'not_started' | 'unknown'; +/** Stable HTTP error kind for a live effort update whose default was not saved. */ +export const REASONING_EFFORT_PERSISTENCE_ERROR_KIND = + 'reasoning_effort_persistence_failed' as const; + /** * Closed taxonomy of structured error categories surfaced on diagnostic * status cells (workspace preflight, env, MCP guardrails). SDK consumers diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index f3fddd28393..35db4eaec22 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -8,6 +8,7 @@ export { PERMISSION_MODES } from './types/permission-mode.js'; export { DAEMON_APPROVAL_MODES, DAEMON_ERROR_KINDS, + REASONING_EFFORT_PERSISTENCE_ERROR_KIND, DAEMON_KNOWN_EVENT_TYPE_VALUES, PENDING_PROMPT_ADDED_EVENT, PENDING_PROMPT_STARTED_EVENT, diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index f7b456aa6b7..4bacd8acc8c 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -692,14 +692,8 @@ "type": "string" }, "reasoningEffort": { - "description": "How hard reasoning-capable models think, applied across all providers. Set with /effort. Each provider maps and clamps this to what the active model supports (e.g. Gemini caps at \"high\"; Anthropic clamps tiers a model lacks). Leave unset to use the model/provider default. Options: low, medium, high, xhigh, max", - "enum": [ - "low", - "medium", - "high", - "xhigh", - "max" - ] + "description": "How hard reasoning-capable models think. Values are model-specific; use \"none\" to disable reasoning and leave unset to use the model/provider default.", + "type": "string" }, "maxSessionTurns": { "description": "Maximum number of user/model/tool turns to keep in a session. Must be an integer; -1 means unlimited.", diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 508fa51b2cd..c689dac664f 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -47,7 +47,7 @@ type MockConnection = { reasoningPreview?: { enabled: boolean; effort: string; - efforts: string[]; + efforts: Array<{ value: string; name: string }>; defaultEffort: string; canDisable?: boolean; }; @@ -111,7 +111,7 @@ type ChatEditorTestProps = { reasoning?: { enabled: boolean; effort: string; - efforts: string[]; + efforts: Array<{ value: string; name: string }>; defaultEffort: string; canDisable?: boolean; }; @@ -14434,7 +14434,11 @@ describe('App session callbacks', () => { const reasoningPreview = (canDisable: boolean) => ({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ], defaultEffort: 'xhigh', canDisable, }); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index acd7d6f3a23..bef13f08f81 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -6090,7 +6090,9 @@ export function App({ reasoningPreview && (reasoningIntent.value === 'none' ? reasoningPreview.canDisable !== false - : reasoningPreview.efforts.includes(reasoningIntent.value)) + : reasoningPreview.efforts.some( + (option) => option.value === reasoningIntent.value, + )) ? reasoningIntent.value : undefined; const modeId = @@ -6625,7 +6627,9 @@ export function App({ welcomeReasoningPreview && (pendingReasoningIntent.value === 'none' ? welcomeReasoningPreview.canDisable !== false - : welcomeReasoningPreview.efforts.includes(pendingReasoningIntent.value)) + : welcomeReasoningPreview.efforts.some( + (option) => option.value === pendingReasoningIntent.value, + )) ? pendingReasoningIntent : undefined; const displayedReasoning = hasAuthoritativeReasoningContext @@ -8354,9 +8358,7 @@ export function App({ ]); const handleCycleMode = useCallback(() => { - const idx = isDaemonApprovalMode(currentMode) - ? MODES_CYCLE.indexOf(currentMode) - : -1; + const idx = MODES_CYCLE.findIndex((mode) => mode === currentMode); const next = MODES_CYCLE[(idx + 1) % MODES_CYCLE.length]; handleSetMode(next); }, [currentMode, handleSetMode]); @@ -11351,7 +11353,7 @@ export function App({ if (preview.canDisable === false) return; const previousEffort = previous?.modelId === modelId && - preview.efforts.includes(previous.effort) + preview.efforts.some((option) => option.value === previous.effort) ? previous.effort : preview.effort; setPendingReasoningIntent({ @@ -11361,7 +11363,7 @@ export function App({ }); return; } - if (!preview.efforts.includes(value)) return; + if (!preview.efforts.some((option) => option.value === value)) return; setPendingReasoningIntent({ modelId, value, effort: value }); }, [setPendingReasoningIntent], diff --git a/packages/web-shell/client/components/ChatEditor.test.tsx b/packages/web-shell/client/components/ChatEditor.test.tsx index d7c8c06b9f3..b2a5996ba4f 100644 --- a/packages/web-shell/client/components/ChatEditor.test.tsx +++ b/packages/web-shell/client/components/ChatEditor.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, createRef } from 'react'; +import { act, createRef, type ComponentProps } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -10,7 +10,7 @@ import { type WebShellComposerTag, type WebShellCustomization, } from '../customization'; -import { I18nProvider } from '../i18n'; +import { I18nProvider, type WebShellLanguage } from '../i18n'; import type { MobileComposerBackend, SlashMenuState, @@ -349,6 +349,7 @@ interface ChatEditorRenderProps { currentMode?: string; currentModel?: string; availableModels?: Array<{ id: string; label?: string }>; + reasoning?: ComponentProps['reasoning']; sessionWorkflowEnabled?: boolean; onSelectMode?: (mode: string) => void; onSelectModel?: (model: string) => void; @@ -369,6 +370,7 @@ interface ChatEditorRenderProps { builtinAtProviders?: WebShellCustomization['builtinAtProviders']; atProviders?: WebShellCustomization['atProviders']; skills?: Array<{ name: string; description: string }>; + language?: WebShellLanguage; } function renderChatEditorInto( @@ -383,6 +385,7 @@ function renderChatEditorInto( customization, renderComposerTagTooltip, onComposerTagClick, + language = 'en', ...chatEditorProps } = props; if (composerTags) { @@ -405,7 +408,7 @@ function renderChatEditorInto( onComposerTagClick, }} > - + undefined} commands={[]} @@ -1618,6 +1621,85 @@ describe('ChatEditor toolbar popovers', () => { expect(onSelectModel).toHaveBeenCalledWith('qwen-max'); }); + it('localizes built-in reasoning efforts and preserves provider labels', () => { + const container = renderChatEditor({ + language: 'zh-CN', + visibleToolbarActions: ['model'], + currentModel: 'qwen-max', + availableModels: [{ id: 'qwen-max', label: 'Qwen Max' }], + reasoning: { + enabled: true, + effort: 'medium', + efforts: [ + { value: 'default', name: 'Default' }, + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'high', name: 'High' }, + { value: 'xhigh', name: 'Extra High' }, + { value: 'max', name: 'Max' }, + { value: 'Vendor.Ultra', name: 'Vendor Ultra' }, + ], + }, + }); + + const modelButton = container.querySelector( + '[data-web-shell-model-button]', + ); + expect(modelButton?.textContent).toContain('Qwen Max · 中'); + + act(() => modelButton?.click()); + + const efforts = Array.from( + document.querySelectorAll( + '[data-web-shell-model-reasoning] [data-web-shell-effort]', + ), + (button) => button.textContent, + ); + expect(efforts).toEqual([ + '默认', + '低', + '中', + '高', + '极高', + '最高', + 'Vendor Ultra', + ]); + + const customContainer = renderChatEditor({ + language: 'zh-CN', + visibleToolbarActions: ['model'], + currentModel: 'qwen-max', + availableModels: [{ id: 'qwen-max', label: 'Qwen Max' }], + reasoning: { + enabled: true, + effort: 'Vendor.Ultra', + efforts: [{ value: 'Vendor.Ultra', name: 'Vendor Ultra' }], + }, + }); + expect( + customContainer.querySelector( + '[data-web-shell-model-button]', + )?.textContent, + ).toContain('Qwen Max · Vendor Ultra'); + + const englishContainer = renderChatEditor({ + language: 'en', + visibleToolbarActions: ['model'], + currentModel: 'qwen-max', + availableModels: [{ id: 'qwen-max', label: 'Qwen Max' }], + reasoning: { + enabled: true, + effort: 'xhigh', + efforts: [{ value: 'xhigh', name: 'Extra high' }], + }, + }); + expect( + englishContainer.querySelector( + '[data-web-shell-model-button]', + )?.textContent, + ).toContain('Qwen Max · Extra high'); + }); + it('displays the model label instead of an opaque route id', () => { const routeId = 'qwen-route:v1:abcdefghijklmnop'; const container = renderChatEditor({ diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 09a6630d7ad..652bdcb9bf1 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -29,7 +29,7 @@ import type { } from '@qwen-code/sdk/daemon'; import type { CommandDisplayCategoryOrder } from '../utils/commandDisplay'; import type { SkillInfo } from '../completions/slashCompletion'; -import { useI18n } from '../i18n'; +import { useI18n, type WebShellLanguage } from '../i18n'; import type { DaemonReasoningControls } from '@qwen-code/webui/daemon-react-sdk'; import { useWebShellPortalRoot } from '../portalRoot'; import { @@ -171,6 +171,25 @@ const ACTIVE_TOOLBAR_ACTION_SET = new Set( ACTIVE_TOOLBAR_ACTIONS, ); +const BUILT_IN_REASONING_EFFORT_LABEL_KEYS: Record = { + default: 'reasoning.effort.default', + low: 'reasoning.effort.low', + medium: 'reasoning.effort.medium', + high: 'reasoning.effort.high', + xhigh: 'reasoning.effort.xhigh', + max: 'reasoning.effort.max', +}; + +function getReasoningEffortLabel( + effort: { value: string; name: string }, + language: WebShellLanguage, + t: (key: string) => string, +): string { + if (language === 'en') return effort.name; + const key = BUILT_IN_REASONING_EFFORT_LABEL_KEYS[effort.value]; + return key ? t(key) : effort.name; +} + interface ChatEditorProps { onSubmit: ( text: string, @@ -1066,7 +1085,7 @@ function ModelReasoningControls({ reasoning: DaemonReasoningControls; onSelect?: (value: string) => Promise | void; }) { - const { t } = useI18n(); + const { language, t } = useI18n(); const [busy, setBusy] = useState(false); const hasEffortOptions = reasoning.efforts.length > 0; const select = async (value: string) => { @@ -1106,17 +1125,17 @@ function ModelReasoningControls({ {reasoning.efforts.map((effort) => ( ))} @@ -1682,7 +1701,7 @@ export const ChatEditor = memo( editorTheme: CHAT_EDITOR_THEME, }); - const { t } = useI18n(); + const { language, t } = useI18n(); useImperativeHandle(ref, () => core.handle, [core.handle]); @@ -2379,9 +2398,14 @@ export const ChatEditor = memo( lastConfirmedModelLabel, }); const showReasoningOptions = Boolean(reasoning); + const selectedReasoningEffort = reasoning?.efforts.find( + (option) => option.value === reasoning.effort, + ); const reasoningEffortLabel = reasoning ? reasoning.efforts.length > 0 - ? t(`reasoning.effort.${reasoning.effort}`) + ? selectedReasoningEffort + ? getReasoningEffortLabel(selectedReasoningEffort, language, t) + : reasoning.effort : t('reasoning.thinking') : ''; const modelChipLabel = showReasoningOptions diff --git a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts index 1f472d7b9dc..b9f62b4bef6 100644 --- a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts @@ -35,9 +35,10 @@ const qwen38ReasoningConfigOptions = (currentValue = 'xhigh') => [ currentValue, options: [ { value: 'none', name: 'Thinking off' }, + { value: 'default', name: 'Default' }, { value: 'low', name: 'Low' }, { value: 'medium', name: 'Medium' }, - { value: 'xhigh', name: 'Extra high' }, + { value: 'xhigh', name: 'Extra High' }, ], _meta: { 'qwenCode/reasoning': { defaultEffort: 'xhigh' }, @@ -258,6 +259,9 @@ test('configures qwen3.8-max reasoning from the model popover @smoke', async ({ const xhigh = controls.locator('[data-web-shell-effort="xhigh"]'); await expect(controls).toBeVisible(); await expect(modelSubmenu).toBeVisible(); + await expect( + controls.locator('[data-web-shell-effort="default"]'), + ).toHaveCount(0); await expect(modelButton).toContainText('Extra High'); await expect(thinking).toBeChecked(); await expect(xhigh).toHaveAttribute('aria-pressed', 'true'); @@ -297,6 +301,43 @@ test('configures qwen3.8-max reasoning from the model popover @smoke', async ({ ).toBeVisible(); }); +test('preserves a model-specific reasoning effort value and name @smoke', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario({ + currentModel: 'claude-ultra', + state: { + configOptions: [ + { + id: 'reasoning_effort', + name: 'Reasoning effort', + type: 'select', + currentValue: 'medium', + options: [ + { value: 'none', name: 'Thinking Off' }, + { value: 'medium', name: 'Medium' }, + { value: 'ultra', name: 'Vendor Ultra' }, + ], + }, + ], + }, + }); + const daemon = await installScenario(page, scenario, testInfo); + await gotoSession(page, scenario, daemon); + + const modelButton = page.locator('[data-web-shell-model-button]'); + await modelButton.click(); + const ultra = page.locator('[data-web-shell-effort="ultra"]'); + await expect(ultra).toContainText('Vendor Ultra'); + await ultra.click(); + + await expect.poll(() => daemon.configOptionRequests().length).toBe(1); + expect( + requestBodyRecord(firstRequest(daemon.configOptionRequests())), + ).toEqual({ configId: 'reasoning_effort', value: 'ultra' }); + await expect(modelButton).toContainText('Vendor Ultra'); +}); + test('keeps mandatory qwen3.8-max effort switchable after messages and while running @smoke', async ({ page, }, testInfo) => { @@ -583,7 +624,7 @@ test('keeps mandatory qwen3.8-max effort switchable before lazy session creation const thinking = page.locator('[data-web-shell-thinking-toggle]'); await expect(thinking).toBeChecked(); await expect(thinking).toBeDisabled(); - await expect(modelButton).toContainText('Extra High'); + await expect(modelButton).toContainText('Extra high'); const medium = page.locator('[data-web-shell-effort="medium"]'); await expect(medium).toBeEnabled(); await medium.click(); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index a49dc795a99..fad881340b4 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2304,9 +2304,12 @@ const EN: Messages = { 'reasoning.thinking': 'Thinking', 'reasoning.thinkingOff': 'Thinking Off', 'reasoning.effort': 'Effort', + 'reasoning.effort.default': 'Default', 'reasoning.effort.low': 'Low', 'reasoning.effort.medium': 'Medium', + 'reasoning.effort.high': 'High', 'reasoning.effort.xhigh': 'Extra High', + 'reasoning.effort.max': 'Max', 'reasoning.updateFailed': 'Failed to update reasoning options', 'model.setFast': 'Set Fast Model', 'model.setVoice': 'Set Voice Model', @@ -5310,9 +5313,12 @@ const ZH: Messages = { 'reasoning.thinking': '思考', 'reasoning.thinkingOff': '思考已关闭', 'reasoning.effort': '思考强度', + 'reasoning.effort.default': '默认', 'reasoning.effort.low': '低', 'reasoning.effort.medium': '中', + 'reasoning.effort.high': '高', 'reasoning.effort.xhigh': '极高', + 'reasoning.effort.max': '最高', 'reasoning.updateFailed': '更新思考选项失败', 'model.setFast': '设置 Fast Model', 'model.setVoice': '设置语音模型', diff --git a/packages/webui/src/daemon-react-sdk.ts b/packages/webui/src/daemon-react-sdk.ts index 63734f57581..ab216d35059 100644 --- a/packages/webui/src/daemon-react-sdk.ts +++ b/packages/webui/src/daemon-react-sdk.ts @@ -219,6 +219,7 @@ export type { /** Prompt submission status: `'idle' | 'waiting' | 'streaming'`. */ DaemonPromptStatus, DaemonReasoningControls, + DaemonReasoningEffortOption, /** Hook return value for daemon follow-up suggestions. */ UseDaemonFollowupSuggestionReturn, /** Image attachment (base64 data + MIME type) for prompt submission. */ diff --git a/packages/webui/src/daemon/index.ts b/packages/webui/src/daemon/index.ts index c1600c74f67..7102525039e 100644 --- a/packages/webui/src/daemon/index.ts +++ b/packages/webui/src/daemon/index.ts @@ -47,6 +47,7 @@ export type { DaemonPromptImage, DaemonPromptStatus, DaemonReasoningControls, + DaemonReasoningEffortOption, DaemonSessionActions, DaemonSessionContextValue, DaemonSessionNotice, diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx index 572c9b4e536..61fcb2a63c0 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.test.tsx @@ -53,6 +53,8 @@ import { getSidechannelMidTurnInjected, } from '../midTurnInjectedSidechannel.js'; import { persistStableClientId } from './clientLifecycle.js'; +import { mapProviderStatus } from './mappers.js'; +import { getWorkspaceModelsAfterSessionClear } from './actions.js'; interface MockSession { sessionId: string; @@ -82,6 +84,10 @@ interface MockSession { removeMidTurnMessage: (messageId: string) => Promise<{ removed: boolean }>; cancel: () => Promise; setModel: (modelId: string) => Promise<{ modelId: string }>; + setConfigOption: ( + configId: string, + value: string, + ) => Promise<{ configOptions: unknown[] }>; heartbeat: () => Promise<{ ok: boolean }>; shellCommand: (command: string, signal?: AbortSignal) => Promise; goal: () => Promise; @@ -140,7 +146,9 @@ interface MockClient { workspaceAcpStatus: () => Promise; workspaceAcpPreheat: () => Promise; workspaceGit: () => Promise; - workspaceByCwd: (workspaceCwd: string) => Pick; + workspaceByCwd: ( + workspaceCwd: string, + ) => Pick; workspaceTools: () => Promise; setWorkspaceToolEnabled: () => Promise; workspaceMemory: () => Promise; @@ -183,6 +191,7 @@ const sdkMocks = vi.hoisted(() => { const sessions: MockSession[] = []; const capabilities = vi.fn(); const workspaceProviders = vi.fn(); + const qualifiedWorkspaceProviders = vi.fn(); const listWorkspaceSessions = vi.fn(); const closeSession = vi.fn(); const setSessionApprovalMode = vi.fn(); @@ -193,7 +202,10 @@ const sdkMocks = vi.hoisted(() => { const workspaceAcpStatus = vi.fn(); const workspaceAcpPreheat = vi.fn(); const workspaceGit = vi.fn(); - const workspaceByCwd = vi.fn((_workspaceCwd: string) => ({ workspaceGit })); + const workspaceByCwd = vi.fn((_workspaceCwd: string) => ({ + workspaceGit, + workspaceProviders: qualifiedWorkspaceProviders, + })); const workspaceTools = vi.fn(); const setWorkspaceToolEnabled = vi.fn(); const workspaceMemory = vi.fn(); @@ -279,6 +291,7 @@ const sdkMocks = vi.hoisted(() => { sessions, capabilities, workspaceProviders, + qualifiedWorkspaceProviders, workspaceSkills, workspaceAcpStatus, workspaceAcpPreheat, @@ -306,6 +319,13 @@ const sdkMocks = vi.hoisted(() => { initialized: true, providers: [], }); + qualifiedWorkspaceProviders.mockReset(); + qualifiedWorkspaceProviders.mockResolvedValue({ + v: 1, + workspaceCwd: '/mock-workspace', + initialized: true, + providers: [], + }); listWorkspaceSessions.mockReset(); listWorkspaceSessions.mockResolvedValue([]); closeSession.mockReset(); @@ -351,6 +371,7 @@ const sdkMocks = vi.hoisted(() => { workspaceByCwd.mockReset(); workspaceByCwd.mockImplementation((_workspaceCwd: string) => ({ workspaceGit, + workspaceProviders: qualifiedWorkspaceProviders, })); workspaceTools.mockReset(); workspaceTools.mockResolvedValue({ @@ -582,7 +603,11 @@ describe('DaemonSessionProvider', () => { expect(connection?.models?.[0]?.reasoningPreview).toEqual({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], }); const providerActions = requireActions(actions); @@ -606,7 +631,11 @@ describe('DaemonSessionProvider', () => { expect(connection?.reasoning).toEqual({ enabled: true, effort: 'medium', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], }); }); @@ -713,8 +742,254 @@ describe('DaemonSessionProvider', () => { expect(connection?.models?.[0]?.reasoningPreview).toEqual({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], + }); + }); + + it('refreshes the authoritative default after a persisted effort is cleared', async () => { + sdkMocks.workspaceProviders + .mockResolvedValueOnce(workspaceProvidersWithReasoningPreview('medium')) + .mockResolvedValue(workspaceProvidersWithReasoningPreview('xhigh')); + sdkMocks.qualifiedWorkspaceProviders.mockResolvedValue( + workspaceProvidersWithReasoningPreview('xhigh'), + ); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'clear-reasoning-session', + context: vi.fn(async () => ({ + v: 1 as const, + sessionId: 'clear-reasoning-session', + workspaceCwd: '/mock-workspace', + state: { + configOptions: reasoningConfigOptions('medium'), + models: { + currentModelId: 'qwen3.8-max', + availableModels: [ + { + modelId: 'qwen3.8-max', + baseModelId: 'qwen3.8-max', + name: 'Qwen 3.8 Max', + }, + ], + }, + }, + })), + events: async function* clearedReasoningEvents( + opts: { signal?: AbortSignal } = {}, + ) { + yield { + id: 1, + v: 1, + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: null, + scope: 'user', + }, + } satisfies DaemonEvent; + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + }, + }), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'clear-reasoning-session', }); + await vi.waitFor(() => + expect(sdkMocks.qualifiedWorkspaceProviders).toHaveBeenCalled(), + ); + await vi.waitFor(() => + expect( + mapProviderStatus(connection?.providers).models[0]?.reasoningPreview + ?.effort, + ).toBe('xhigh'), + ); + expect(connection?.sessionId).toBe('clear-reasoning-session'); + expect( + getWorkspaceModelsAfterSessionClear(connection!)?.[0]?.reasoningPreview + ?.effort, + ).toBe('xhigh'); + + await act(async () => { + await actions?.clearSession(); + await flushPromises(); + }); + + expect(connection?.sessionId).toBeUndefined(); + expect(connection?.models?.[0]?.reasoningPreview?.effort).toBe('xhigh'); + }); + + it('drops a reasoning provider refresh after switching workspaces', async () => { + const staleRefresh = + createDeferred< + ReturnType + >(); + sdkMocks.workspaceProviders + .mockResolvedValueOnce( + workspaceProvidersWithReasoningPreview('medium', '/work/a'), + ) + .mockResolvedValue( + workspaceProvidersWithReasoningPreview('xhigh', '/work/b'), + ); + sdkMocks.qualifiedWorkspaceProviders.mockReturnValue(staleRefresh.promise); + sdkMocks.sessions.push( + createMockSession({ + sessionId: 'session-a', + workspaceCwd: '/work/a', + events: async function* workspaceASettingsEvent( + opts: { signal?: AbortSignal } = {}, + ) { + yield { + id: 1, + v: 1, + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: null, + scope: 'user', + }, + } satisfies DaemonEvent; + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + }, + }), + createMockSession({ sessionId: 'session-b', workspaceCwd: '/work/b' }), + ); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'session-a', + workspaceCwd: '/work/a', + }); + await vi.waitFor(() => + expect(sdkMocks.qualifiedWorkspaceProviders).toHaveBeenCalled(), + ); + + let switched: Promise | undefined; + act(() => { + switched = requireActions(actions).loadSession('session-b', { + workspaceCwd: '/work/b', + }); + }); + await act(async () => { + await switched; + await flushPromises(); + }); + expect(connection).toMatchObject({ + sessionId: 'session-b', + workspaceCwd: '/work/b', + providers: { workspaceCwd: '/work/b' }, + }); + + staleRefresh.resolve( + workspaceProvidersWithReasoningPreview('medium', '/work/a'), + ); + await act(async () => { + await flushPromises(); + }); + + expect(connection).toMatchObject({ + sessionId: 'session-b', + workspaceCwd: '/work/b', + providers: { workspaceCwd: '/work/b' }, + }); + }); + + it('refreshes persisted reasoning preview from the active session workspace', async () => { + sdkMocks.qualifiedWorkspaceProviders.mockResolvedValue( + workspaceProvidersWithReasoningPreview('medium', '/secondary-workspace'), + ); + const session = createMockSession({ + sessionId: 'secondary-session', + workspaceCwd: '/secondary-workspace', + context: vi.fn(async () => ({ + v: 1 as const, + sessionId: 'secondary-session', + workspaceCwd: '/secondary-workspace', + state: { + configOptions: reasoningConfigOptions('xhigh'), + models: { + currentModelId: 'qwen3.8-max', + availableModels: [ + { + modelId: 'qwen3.8-max', + baseModelId: 'qwen3.8-max', + name: 'Qwen 3.8 Max', + contextLimit: 131_072, + }, + ], + }, + }, + })), + }); + sdkMocks.sessions.push(session); + let actions: DaemonSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + sessionId: 'secondary-session', + }); + await act(async () => { + await flushPromises(); + }); + const primaryProviderReads = sdkMocks.workspaceProviders.mock.calls.length; + + await act(async () => { + await actions?.setReasoningEffort('medium'); + }); + + expect(sdkMocks.workspaceByCwd).toHaveBeenCalledWith( + '/secondary-workspace', + ); + expect(sdkMocks.qualifiedWorkspaceProviders).toHaveBeenCalledOnce(); + expect(sdkMocks.workspaceProviders).toHaveBeenCalledTimes( + primaryProviderReads, + ); + expect(connection?.providers?.workspaceCwd).toBe('/secondary-workspace'); + expect(connection?.reasoning?.effort).toBe('medium'); }); it('populates git branch from the active session workspace', async () => { @@ -9489,7 +9764,11 @@ describe('DaemonSessionProvider', () => { expect(connection?.models?.[0]?.reasoningPreview).toEqual({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], }); }); @@ -10873,7 +11152,11 @@ describe('DaemonSessionProvider', () => { expect(connection?.models?.[0]?.reasoningPreview).toEqual({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], }); await act(async () => { await expect(providerActions.cancel()).rejects.toThrow( @@ -10922,7 +11205,11 @@ describe('DaemonSessionProvider', () => { expect(connection?.models?.[0]?.reasoningPreview).toEqual({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], }); }); @@ -12524,7 +12811,11 @@ describe('DaemonSessionProvider', () => { expect(connection?.models?.[0]?.reasoningPreview).toEqual({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], }); }); @@ -15167,10 +15458,13 @@ function reasoningConfigOptions(currentValue: string): unknown[] { ]; } -function workspaceProvidersWithReasoningPreview() { +function workspaceProvidersWithReasoningPreview( + currentValue = 'xhigh', + workspaceCwd = '/mock-workspace', +) { return { v: 1, - workspaceCwd: '/mock-workspace', + workspaceCwd, initialized: true, current: { modelId: 'qwen3.8-max' }, providers: [ @@ -15186,7 +15480,7 @@ function workspaceProvidersWithReasoningPreview() { name: 'Qwen 3.8 Max', isCurrent: true, isRuntime: false, - configOptions: reasoningConfigOptions('xhigh'), + configOptions: reasoningConfigOptions(currentValue), }, ], }, @@ -15256,6 +15550,11 @@ function createMockSession(opts: Partial = {}): MockSession { vi.fn(async (modelId: string) => ({ modelId, })), + setConfigOption: + opts.setConfigOption ?? + vi.fn(async () => ({ + configOptions: reasoningConfigOptions('medium'), + })), heartbeat: opts.heartbeat ?? vi.fn(async () => ({ ok: true })), shellCommand: opts.shellCommand ?? vi.fn(async () => undefined), goal: diff --git a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx index b9debc05224..753abc53059 100644 --- a/packages/webui/src/daemon/session/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/session/DaemonSessionProvider.tsx @@ -677,6 +677,7 @@ const INITIAL_WORKSPACE_EVENT_SIGNALS: DaemonWorkspaceEventSignals = { agentsVersion: 0, toolsVersion: 0, settingsVersion: 0, + reasoningSettingsVersion: 0, skillsVersion: 0, mcpVersion: 0, extensionsVersion: 0, @@ -1071,6 +1072,7 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { ); const [workspaceEventSignals, setWorkspaceEventSignals] = useState(INITIAL_WORKSPACE_EVENT_SIGNALS); + const reasoningProvidersRefreshRef = useRef(0); const hasCurrentSessionActivePromptRef = useRef<() => boolean>(() => false); const mountedRef = useRef(false); @@ -1084,6 +1086,82 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { }; }, []); + useEffect(() => { + if (workspaceEventSignals.reasoningSettingsVersion === 0) return; + const client = + workspaceClientRef.current ?? + (resolvedBaseUrl + ? new DaemonClient({ baseUrl: resolvedBaseUrl, token: resolvedToken }) + : undefined); + if (!client) return; + const refreshId = ++reasoningProvidersRefreshRef.current; + const targetWorkspace = + sessionRef.current?.workspaceCwd ?? + activeWorkspaceCwdRef.current ?? + connectionRef.current.workspaceCwd ?? + resolvedWorkspaceCwdRef.current; + const targetWorkspaceKey = normalizeWorkspaceIdentity(targetWorkspace); + const load = targetWorkspace + ? client.workspaceByCwd(targetWorkspace).workspaceProviders() + : client.workspaceProviders(); + void load + .then((providers) => { + if ( + !mountedRef.current || + refreshId !== reasoningProvidersRefreshRef.current + ) { + return; + } + const responseWorkspaceKey = normalizeWorkspaceIdentity( + providers.workspaceCwd, + ); + if ( + targetWorkspaceKey && + responseWorkspaceKey && + responseWorkspaceKey !== targetWorkspaceKey + ) { + return; + } + const projected = mapProviderStatus(providers); + setConnectionSynchronous((current) => { + const currentWorkspaceKey = normalizeWorkspaceIdentity( + sessionRef.current?.workspaceCwd ?? + activeWorkspaceCwdRef.current ?? + current.workspaceCwd ?? + resolvedWorkspaceCwdRef.current, + ); + if ( + targetWorkspaceKey && + currentWorkspaceKey !== targetWorkspaceKey + ) { + return current; + } + const activeSession = sessionRef.current; + if (activeSession && current.sessionId === activeSession.sessionId) { + return { ...current, providers }; + } + return { + ...current, + providers, + models: projected.models, + currentModel: projected.currentModel ?? current.currentModel, + contextWindow: projected.contextWindow ?? current.contextWindow, + }; + }); + }) + .catch((error: unknown) => { + console.warn( + '[DaemonSessionProvider] failed to refresh providers after reasoning setting change:', + error, + ); + }); + }, [ + resolvedBaseUrl, + resolvedToken, + setConnectionSynchronous, + workspaceEventSignals.reasoningSettingsVersion, + ]); + const sessionEffectWorkspaceCwd = restoreWorkspaceCwd ?? workspaceCwd; useEffect(() => { @@ -3575,6 +3653,15 @@ export function DaemonSessionProvider(props: DaemonSessionProviderProps) { requestClientId, ); }, + refreshWorkspaceProviders: (workspaceCwd) => { + const client = + workspaceClientRef.current ?? + new DaemonClient({ + baseUrl: resolvedBaseUrl!, + token: resolvedToken, + }); + return client.workspaceByCwd(workspaceCwd).workspaceProviders(); + }, getConnection: () => connectionRef.current, addNotice, setConnection, @@ -4503,6 +4590,7 @@ function bumpWorkspaceEventSignals( let agents = 0; let tools = 0; let settings = 0; + let reasoningSettings = 0; const skillMutations: Array< NonNullable > = []; @@ -4535,6 +4623,9 @@ function bumpWorkspaceEventSignals( } } else { settings += 1; + if (event.key === 'model.reasoningEffort') { + reasoningSettings += 1; + } } break; case 'workspace.mcp.budget_warning': @@ -4615,6 +4706,8 @@ function bumpWorkspaceEventSignals( agentsVersion: current.agentsVersion + agents, toolsVersion: current.toolsVersion + tools, settingsVersion: current.settingsVersion + settings, + reasoningSettingsVersion: + current.reasoningSettingsVersion + reasoningSettings, skillsVersion: current.skillsVersion + newSkillMutations.length, mcpVersion: current.mcpVersion + mcp, extensionsVersion: current.extensionsVersion + extensions, diff --git a/packages/webui/src/daemon/session/actions.test.ts b/packages/webui/src/daemon/session/actions.test.ts index 4af5cdab8f6..900cd3e2208 100644 --- a/packages/webui/src/daemon/session/actions.test.ts +++ b/packages/webui/src/daemon/session/actions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'; import { DaemonHttpError, DaemonPendingPromptLimitError, + REASONING_EFFORT_PERSISTENCE_ERROR_KIND, DaemonTransportClosedError, type DaemonCapabilities, type DaemonSessionClient, @@ -149,7 +150,11 @@ describe('getConnectionAfterSessionClear', () => { expect(next.models?.[0]?.reasoningPreview).toEqual({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], }); }); @@ -2450,6 +2455,193 @@ describe('createDaemonSessionActions', () => { }); }); + it('refreshes the persisted reasoning preview before the session is cleared', async () => { + const source = createMockSession( + 'session-a', + 'client-a', + '/secondary-workspace', + ); + const configOptions = [ + { + id: 'reasoning_effort', + name: 'Reasoning effort', + type: 'select', + currentValue: 'medium', + options: [ + { value: 'none', name: 'Thinking off' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ], + }, + ]; + const providers = { + v: 1 as const, + workspaceCwd: '/workspace', + initialized: true, + current: { modelId: 'qwen3.8-max' }, + providers: [ + { + kind: 'model_provider' as const, + status: 'ok' as const, + authType: 'qwen-oauth', + current: true, + models: [ + { + modelId: 'qwen3.8-max', + baseModelId: 'qwen3.8-max', + name: 'Qwen 3.8 Max', + isCurrent: true, + isRuntime: false, + configOptions, + }, + ], + }, + ], + }; + source.setConfigOption.mockResolvedValueOnce({ configOptions }); + const refreshWorkspaceProviders = vi.fn().mockResolvedValue(providers); + const { actions, getConnection } = createActionsHarness({ + session: source, + refreshWorkspaceProviders, + connection: { + status: 'connected', + workspaceCwd: '/workspace', + sessionId: source.sessionId, + clientId: source.clientId, + currentModel: 'qwen3.8-max', + providers: { + ...providers, + providers: providers.providers.map((provider) => ({ + ...provider, + models: provider.models.map((model) => ({ + ...model, + configOptions: configOptions.map((option) => ({ + ...option, + currentValue: 'xhigh', + })), + })), + })), + }, + }, + }); + + await actions.setReasoningEffort('medium'); + + const cleared = getConnectionAfterSessionClear( + getConnection(), + source.sessionId, + ); + expect(refreshWorkspaceProviders).toHaveBeenCalledOnce(); + expect(refreshWorkspaceProviders).toHaveBeenCalledWith( + '/secondary-workspace', + ); + expect(cleared.models?.[0]?.reasoningPreview?.effort).toBe('medium'); + }); + + it('refreshes live reasoning after the default settings write fails', async () => { + const source = createMockSession('session-a', 'client-a'); + const efforts = [ + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ]; + const configOptions = [ + { + id: 'reasoning_effort', + name: 'Reasoning effort', + type: 'select', + currentValue: 'xhigh', + options: [{ value: 'none', name: 'Thinking off' }, ...efforts], + }, + ]; + const liveContext = { + ...contextStatus(source.sessionId), + state: { configOptions }, + }; + source.setConfigOption.mockRejectedValueOnce( + new DaemonHttpError( + 500, + { + data: { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + }, + 'Internal error', + ), + ); + source.context.mockResolvedValueOnce(liveContext); + const addNotice = vi.fn(); + const { actions, getConnection } = createActionsHarness({ + session: source, + addNotice, + connection: { + status: 'connected', + workspaceCwd: '/workspace', + sessionId: source.sessionId, + clientId: source.clientId, + currentModel: 'qwen3.8-max', + context: { + ...contextStatus(source.sessionId), + state: {}, + }, + reasoning: { enabled: true, effort: 'medium', efforts }, + }, + }); + + await expect(actions.setReasoningEffort('xhigh')).resolves.toBeUndefined(); + + expect(source.context).toHaveBeenCalledOnce(); + expect(addNotice).toHaveBeenCalledWith( + expect.objectContaining({ + message: + 'Set reasoning effort failed: The current session was updated, but the default value was not saved', + }), + ); + expect(getConnection()).toMatchObject({ + context: liveContext, + reasoning: { enabled: true, effort: 'xhigh', efforts }, + }); + }); + + it('rejects a persistence failure when live reasoning cannot confirm the selection', async () => { + const source = createMockSession('session-a', 'client-a'); + source.setConfigOption.mockRejectedValueOnce( + new DaemonHttpError( + 500, + { + data: { errorKind: REASONING_EFFORT_PERSISTENCE_ERROR_KIND }, + }, + 'Internal error', + ), + ); + source.context.mockResolvedValueOnce({ + ...contextStatus(source.sessionId), + state: { configOptions: reasoningConfigOptions('medium') }, + }); + const { actions } = createActionsHarness({ + session: source, + connection: { + status: 'connected', + workspaceCwd: '/workspace', + sessionId: source.sessionId, + clientId: source.clientId, + currentModel: 'qwen3.8-max', + reasoning: { + enabled: true, + effort: 'medium', + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], + }, + }, + }); + + await expect(actions.setReasoningEffort('xhigh')).rejects.toMatchObject({ + message: + 'The current session was updated, but the default value was not saved', + cause: expect.any(DaemonHttpError), + }); + }); + it('applies a reasoning effort only when the daemon confirms it', async () => { const session = createMockSession('session-a'); session.setConfigOption.mockResolvedValueOnce({ @@ -2473,7 +2665,53 @@ describe('createDaemonSessionActions', () => { expect(getConnection().reasoning).toEqual({ enabled: true, effort: 'medium', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], + }); + }); + + it('accepts default when the daemon confirms the resolved model default', async () => { + const session = createMockSession('session-a'); + session.setConfigOption.mockResolvedValueOnce({ + configOptions: [ + { + id: 'reasoning_effort', + currentValue: 'xhigh', + options: [ + { value: 'none', name: 'Thinking off' }, + { value: 'default', name: 'Default' }, + { value: 'xhigh', name: 'Extra High' }, + ], + }, + ], + }); + const { actions, getConnection } = createActionsHarness({ + connection: { + status: 'connected', + sessionId: 'session-a', + currentModel: 'qwen3.8-max', + }, + session, + }); + + await expect( + actions.setReasoningEffort('default'), + ).resolves.toBeUndefined(); + + expect(session.setConfigOption).toHaveBeenCalledWith( + 'reasoning_effort', + 'default', + ); + expect(getConnection().reasoning).toEqual({ + enabled: true, + effort: 'xhigh', + efforts: [ + { value: 'default', name: 'Default' }, + { value: 'xhigh', name: 'Extra High' }, + ], }); }); @@ -2613,6 +2851,7 @@ function createActionsHarness( createDetachedSession?: ReturnType; manualSessionClearRef?: { current: boolean }; pendingSessionLoadRef?: { current: PendingSessionLoad | undefined }; + refreshWorkspaceProviders?: ReturnType; restartEventStream?: ReturnType; session?: ReturnType; setAttachSessionNonce?: ReturnType; @@ -2663,6 +2902,7 @@ function createActionsHarness( 'detached-session', ) as unknown as DaemonSessionClient, )) as () => Promise, + refreshWorkspaceProviders: opts.refreshWorkspaceProviders, getConnection: () => connection, hasSessionActivePrompt: () => false, resetCurrentSessionActivePrompt: vi.fn(), @@ -2694,10 +2934,11 @@ function createActionsHarness( function createMockSession( sessionId: string, clientId = `client-${sessionId}`, + workspaceCwd = '/workspace', ) { return { sessionId, - workspaceCwd: '/workspace', + workspaceCwd, clientId, client: { createOrAttachSession: vi.fn(), @@ -2717,9 +2958,11 @@ function createMockSession( context: vi.fn(async () => contextStatus(sessionId)), detach: vi.fn(async () => undefined), setModel: vi.fn(async () => ({})), - setConfigOption: vi.fn(async (_configId: string, value: string) => ({ - configOptions: reasoningConfigOptions(value), - })), + setConfigOption: vi.fn( + async (_configId: string, value: string) => ({ + configOptions: reasoningConfigOptions(value), + }), + ), uploadAttachment: vi.fn( async (data: Blob, name: string, mimeType: string) => ({ type: mimeType.startsWith('image/') diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index 3ff439e353f..b599020c135 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -24,6 +24,7 @@ import type { DaemonSessionArtifactsEnvelope, DaemonTranscriptStore, DaemonCapabilities, + DaemonWorkspaceProvidersStatus, GoalControlRequest, GoalSnapshotV2, DaemonBranchSessionResult, @@ -35,6 +36,7 @@ import type { import { DaemonHttpError, DaemonPendingPromptLimitError, + REASONING_EFFORT_PERSISTENCE_ERROR_KIND, DaemonTransportClosedError, isDaemonTurnError, isStaleBranchPointError, @@ -113,6 +115,39 @@ function imageAttachmentName(mimeType: string): string { return `image.${extension === 'jpg' ? 'jpeg' : extension || 'img'}`; } +function isReasoningEffortPersistenceFailure(error: unknown): boolean { + if (!(error instanceof DaemonHttpError)) return false; + const body = + typeof error.body === 'object' && error.body !== null + ? (error.body as Record) + : undefined; + const data = + typeof body?.['data'] === 'object' && body['data'] !== null + ? (body['data'] as Record) + : undefined; + return data?.['errorKind'] === REASONING_EFFORT_PERSISTENCE_ERROR_KIND; +} + +const REASONING_EFFORT_PERSISTENCE_FAILURE_MESSAGE = + 'The current session was updated, but the default value was not saved'; + +function createReasoningEffortPersistenceFailure(error: unknown): Error { + return Object.assign( + new Error(REASONING_EFFORT_PERSISTENCE_FAILURE_MESSAGE), + { cause: error }, + ); +} + +function isReasoningEffortConfirmed( + value: string, + reasoning: { enabled: boolean; effort: string } | undefined, +): boolean { + if (!reasoning) return false; + if (value === 'none') return reasoning.enabled === false; + if (value === 'default') return true; + return reasoning.enabled === true && reasoning.effort === value; +} + function promptFilesForTranscript( files: ReturnType, fileReferences: ReadonlyArray, @@ -189,6 +224,9 @@ export interface CreateDaemonSessionActionsArgs { 'approvalMode' | 'sourceType' | 'worktree' | 'branch' >, ) => Promise; + refreshWorkspaceProviders?: ( + workspaceCwd: string, + ) => Promise; getConnection: () => DaemonConnectionState; hasSessionActivePrompt: () => boolean; resetCurrentSessionActivePrompt: () => void; @@ -266,6 +304,7 @@ export function createDaemonSessionActions({ passiveAssistantDoneTimerRef, getCreateSessionRequest, createDetachedSession, + refreshWorkspaceProviders, getConnection, hasSessionActivePrompt, resetCurrentSessionActivePrompt, @@ -1111,15 +1150,18 @@ export function createDaemonSessionActions({ result.configOptions, getConnection().reasoning?.effort, ); - const confirmed = - value === 'none' - ? nextReasoning?.enabled === false - : nextReasoning?.enabled === true && nextReasoning.effort === value; - if (!confirmed) { + if (!isReasoningEffortConfirmed(value, nextReasoning)) { throw new Error( `Daemon did not confirm reasoning effort ${JSON.stringify(value)}`, ); } + let providers: DaemonWorkspaceProvidersStatus | undefined; + try { + providers = await refreshWorkspaceProviders?.(session.workspaceCwd); + } catch { + // The session mutation succeeded; a provider preview refresh is + // best-effort and must not turn it into a failed action. + } const current = getConnection(); if ( sessionRef.current === session && @@ -1139,6 +1181,7 @@ export function createDaemonSessionActions({ const configOptions = result.configOptions; return { ...current, + ...(providers ? { providers } : {}), reasoning: nextReasoning, context: current.context ? { @@ -1150,12 +1193,60 @@ export function createDaemonSessionActions({ }); } } catch (error) { - throw dispatchActionError( + const persistenceFailure = isReasoningEffortPersistenceFailure(error); + let liveReasoningConfirmed = false; + if (persistenceFailure && sessionRef.current === session) { + try { + const context = await withActionTimeout( + session.context(), + 'Refresh reasoning state timed out', + ); + if (Array.isArray(context.state.configOptions)) { + const current = getConnection(); + const nextReasoning = mapSessionContextReasoning( + context, + current.reasoning?.effort, + ); + if ( + sessionRef.current === session && + sourceModelGeneration === modelMutationGeneration && + current.currentModel === sourceModel && + actionToken > appliedReasoningActionToken && + isReasoningEffortConfirmed(value, nextReasoning) + ) { + liveReasoningConfirmed = true; + appliedReasoningActionToken = actionToken; + setConnection((current) => { + if ( + sessionRef.current !== session || + sourceModelGeneration !== modelMutationGeneration || + current.currentModel !== sourceModel + ) { + return current; + } + return { + ...current, + context, + reasoning: nextReasoning, + }; + }); + } + } + } catch { + // Preserve the original persistence error when the best-effort + // live-state refresh is unavailable. + } + } + const actionError = dispatchActionError( noticeForSession(session), 'Set reasoning effort failed', - error, + persistenceFailure + ? createReasoningEffortPersistenceFailure(error) + : error, 'set_reasoning_effort', ); + if (persistenceFailure && liveReasoningConfirmed) return; + throw actionError; } }, diff --git a/packages/webui/src/daemon/session/index.ts b/packages/webui/src/daemon/session/index.ts index 3360bf3a5df..568ff61fd4b 100644 --- a/packages/webui/src/daemon/session/index.ts +++ b/packages/webui/src/daemon/session/index.ts @@ -36,6 +36,7 @@ export type { DaemonPromptImage, DaemonPromptStatus, DaemonReasoningControls, + DaemonReasoningEffortOption, DaemonSessionActions, DaemonSessionContextValue, DaemonSessionNotice, diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index 3ba3faa59d1..97e910ec899 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -78,6 +78,77 @@ const turnComplete: DaemonEvent = { }; describe('mapReasoningControls', () => { + it('preserves model-specific effort values and display names', () => { + expect( + mapReasoningControls([ + { + id: 'reasoning_effort', + currentValue: 'ultra', + options: [ + { value: 'none', name: 'Thinking Off' }, + { value: 'ultra', name: 'Ultra' }, + ], + }, + ]), + ).toEqual({ + enabled: true, + effort: 'ultra', + efforts: [{ value: 'ultra', name: 'Ultra' }], + }); + }); + + it('keeps the ACP default instruction out of the effort list', () => { + const configOptions = [ + { + id: 'reasoning_effort', + currentValue: 'default', + options: [ + { value: 'none', name: 'Thinking Off' }, + { value: 'default', name: 'Default' }, + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ], + _meta: { + 'qwenCode/reasoning': { defaultEffort: 'xhigh' }, + }, + }, + ]; + + expect(mapReasoningControls(configOptions)).toEqual({ + enabled: true, + effort: 'xhigh', + efforts: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ], + }); + }); + + it('keeps default selectable without an authoritative model default', () => { + expect( + mapReasoningControls([ + { + id: 'reasoning_effort', + currentValue: 'default', + options: [ + { value: 'none', name: 'Thinking Off' }, + { value: 'default', name: 'Default' }, + { value: 'low', name: 'Low' }, + ], + }, + ]), + ).toEqual({ + enabled: true, + effort: 'default', + efforts: [ + { value: 'default', name: 'Default' }, + { value: 'low', name: 'Low' }, + ], + }); + }); + it('maps toggle-only reasoning without exposing an effort list', () => { expect( mapReasoningControls([ @@ -115,7 +186,11 @@ describe('mapReasoningControls', () => { ).toEqual({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], canDisable: false, }); }); @@ -144,7 +219,11 @@ describe('mapProviderStatus reasoning preview', () => { expect(result.models[0]?.reasoningPreview).toEqual({ enabled: true, effort: 'xhigh', - efforts: ['low', 'medium', 'xhigh'], + efforts: [ + { value: 'low', name: 'low' }, + { value: 'medium', name: 'medium' }, + { value: 'xhigh', name: 'xhigh' }, + ], }); }); @@ -386,6 +465,219 @@ describe('mapWorkspaceSkills', () => { }); describe('updateConnectionFromDaemonEvent', () => { + it('applies config-option updates only to live reasoning state', () => { + const current: DaemonConnectionState = { + status: 'connected', + currentModel: 'qwen3.8-max', + reasoning: { + enabled: true, + effort: 'xhigh', + efforts: [{ value: 'xhigh', name: 'Extra High' }], + }, + models: [ + { + id: 'qwen3.8-max', + label: 'Qwen 3.8 Max', + reasoningPreview: { + enabled: true, + effort: 'xhigh', + efforts: [{ value: 'xhigh', name: 'Extra High' }], + }, + }, + ], + }; + const next = applyEvent(current, { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'config_option_update', + configOptions: [ + { + id: 'reasoning_effort', + currentValue: 'medium', + options: [ + { value: 'none', name: 'Thinking off' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ], + }, + ], + }, + }, + } as DaemonEvent); + + expect(next.reasoning).toMatchObject({ enabled: true, effort: 'medium' }); + expect(next.models).toBe(current.models); + expect(next.models?.[0]?.reasoningPreview?.effort).toBe('xhigh'); + }); + + it('updates every retained provider preview without changing live session state', () => { + const configOptions = [ + { + id: 'reasoning_effort', + currentValue: 'xhigh', + options: [ + { value: 'none', name: 'Thinking off' }, + { value: 'xhigh', name: 'Extra High' }, + ], + }, + ]; + const providers = workspaceProvidersWithConfigOptions(configOptions); + providers.providers[0]?.models.push({ + modelId: 'second-model', + baseModelId: 'second-model', + name: 'Second model', + isCurrent: false, + isRuntime: false, + configOptions: structuredClone(configOptions), + }); + const current: DaemonConnectionState = { + status: 'connected', + sessionId: 'live-session', + currentModel: 'qwen3.8-max', + providers, + reasoning: { + enabled: true, + effort: 'xhigh', + efforts: [{ value: 'xhigh', name: 'Extra High' }], + }, + models: [ + { + id: 'qwen3.8-max', + label: 'Qwen 3.8 Max', + reasoningPreview: { + enabled: true, + effort: 'xhigh', + efforts: [{ value: 'xhigh', name: 'Extra High' }], + }, + }, + ], + }; + const opaque = applyEvent(current, { + id: 2, + v: 1, + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: 'Vendor.Ultra', + scope: 'user', + }, + } as DaemonEvent); + + expect(opaque.reasoning).toBe(current.reasoning); + expect(opaque.models).toBe(current.models); + expect( + mapProviderStatus(opaque.providers).models.map( + (model) => model.reasoningPreview, + ), + ).toEqual([ + { + enabled: true, + effort: 'Vendor.Ultra', + efforts: [ + { value: 'xhigh', name: 'Extra High' }, + { value: 'Vendor.Ultra', name: 'Vendor.Ultra' }, + ], + }, + { + enabled: true, + effort: 'Vendor.Ultra', + efforts: [ + { value: 'xhigh', name: 'Extra High' }, + { value: 'Vendor.Ultra', name: 'Vendor.Ultra' }, + ], + }, + ]); + + const disabled = applyEvent(opaque, { + id: 3, + v: 1, + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: 'none', + scope: 'user', + }, + } as DaemonEvent); + expect(disabled.reasoning).toBe(current.reasoning); + expect(disabled.models).toBe(current.models); + expect( + mapProviderStatus(disabled.providers).models.map( + (model) => model.reasoningPreview?.enabled, + ), + ).toEqual([false, false]); + }); + + it('preserves mandatory reasoning constraints while patching settings previews', () => { + const providers = workspaceProvidersWithConfigOptions([ + { + id: 'reasoning_effort', + currentValue: 'medium', + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ], + _meta: { + 'qwenCode/reasoning': { + defaultEffort: 'xhigh', + thinkingMandatory: true, + }, + }, + }, + ]); + const projected = mapProviderStatus(providers); + const current: DaemonConnectionState = { + status: 'connected', + providers, + models: projected.models, + currentModel: projected.currentModel, + }; + + const disabled = applyEvent(current, { + id: 4, + v: 1, + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: 'none', + scope: 'user', + }, + } as DaemonEvent); + + expect(disabled.models?.[0]?.reasoningPreview).toEqual({ + enabled: true, + effort: 'xhigh', + efforts: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra High' }, + ], + canDisable: false, + }); + + const opaque = applyEvent( + { status: 'connected', models: projected.models }, + { + id: 5, + v: 1, + type: 'settings_changed', + data: { + key: 'model.reasoningEffort', + value: 'Vendor.Ultra', + scope: 'user', + }, + } as DaemonEvent, + ); + expect(opaque.models?.[0]?.reasoningPreview).toMatchObject({ + enabled: true, + effort: 'Vendor.Ultra', + canDisable: false, + }); + }); + it('updates and clears the authoritative Goal snapshot', () => { const goal = { goalId: 'goal-1', diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 742f57e37d7..a49f9f28aa6 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -145,10 +145,12 @@ export function mapReasoningControls( .find((item) => getString(item, 'id') === 'reasoning_effort'); const rawOptions = option?.['options']; if (!option || !Array.isArray(rawOptions)) return undefined; - const values = rawOptions.flatMap((item) => { - const value = getString(getRecord(item), 'value'); - return value ? [value] : []; + const options = rawOptions.flatMap((item) => { + const record = getRecord(item); + const value = getString(record, 'value'); + return value ? [{ value, name: getString(record, 'name') ?? value }] : []; }); + const values = options.map((item) => item.value); const meta = getRecord(option['_meta']); const reasoningMeta = getRecord(meta?.['qwenCode/reasoning']); const thinkingMandatory = reasoningMeta?.['thinkingMandatory'] === true; @@ -156,23 +158,39 @@ export function mapReasoningControls( const currentValue = getString(option, 'currentValue'); if (!currentValue || !values.includes(currentValue)) return undefined; if (thinkingMandatory && currentValue === 'none') return undefined; - const selectableValues = values.filter((value) => value !== 'none'); - if (selectableValues.length === 0) return undefined; if (reasoningMeta?.['toggleOnly'] === true) { + const enabledValue = options.find((item) => item.value !== 'none')?.value; + if (!enabledValue) return undefined; return { enabled: currentValue !== 'none', - effort: selectableValues[0]!, + effort: enabledValue, efforts: [], ...(thinkingMandatory ? { canDisable: false } : {}), }; } - const efforts = selectableValues; const defaultEffort = getString(reasoningMeta, 'defaultEffort'); + const hasVisibleDefaultEffort = + defaultEffort !== undefined && + defaultEffort !== 'default' && + options.some((item) => item.value === defaultEffort); + const efforts = options.filter( + (item) => + item.value !== 'none' && + (!hasVisibleDefaultEffort || item.value !== 'default'), + ); + const selectableValues = efforts.map((item) => item.value); + if (efforts.length === 0) return undefined; + const visibleCurrentValue = + currentValue === 'default' && hasVisibleDefaultEffort + ? defaultEffort + : currentValue; const effort = - [currentValue, fallbackEffort, defaultEffort].find( + [visibleCurrentValue, fallbackEffort, defaultEffort].find( (value): value is string => - typeof value === 'string' && efforts.includes(value), - ) ?? efforts[0]!; + typeof value === 'string' && + (!hasVisibleDefaultEffort || value !== 'default') && + selectableValues.includes(value), + ) ?? efforts[0]!.value; return { enabled: currentValue !== 'none', effort, @@ -188,6 +206,88 @@ export function mapSessionContextReasoning( return mapReasoningControls(status?.state?.configOptions, fallbackEffort); } +function patchReasoningControls( + controls: DaemonReasoningControls, + value: string, +): DaemonReasoningControls { + if (value === 'none' && controls.canDisable === false) return controls; + if (value === 'none') return { ...controls, enabled: false }; + const efforts = controls.efforts.some((effort) => effort.value === value) + ? controls.efforts + : [...controls.efforts, { value, name: value }]; + return { ...controls, enabled: true, effort: value, efforts }; +} + +function patchReasoningConfigOptions( + configOptions: unknown, + value: string, +): unknown { + if (!Array.isArray(configOptions)) return configOptions; + let changed = false; + const next = configOptions.map((candidate) => { + const option = getRecord(candidate); + if ( + getString(option, 'id') !== 'reasoning_effort' || + !Array.isArray(option?.['options']) + ) { + return candidate; + } + const options = option['options'] as unknown[]; + const values = options.flatMap((item) => { + const optionValue = getString(getRecord(item), 'value'); + return optionValue ? [optionValue] : []; + }); + const meta = getRecord(option['_meta']); + const reasoningMeta = getRecord(meta?.['qwenCode/reasoning']); + const thinkingMandatory = reasoningMeta?.['thinkingMandatory'] === true; + const currentValue = getString(option, 'currentValue'); + const defaultEffort = getString(reasoningMeta, 'defaultEffort'); + const nextValue = + value === 'none' && thinkingMandatory + ? [defaultEffort, currentValue, ...values].find( + (candidate): candidate is string => + typeof candidate === 'string' && + candidate !== 'none' && + values.includes(candidate), + ) + : value; + if (!nextValue) return candidate; + const nextOptions = + nextValue === 'none' || values.includes(nextValue) + ? options + : [...options, { value: nextValue, name: nextValue }]; + changed = true; + return { ...option, currentValue: nextValue, options: nextOptions }; + }); + return changed ? next : configOptions; +} + +function patchProviderReasoningPreviews( + status: DaemonWorkspaceProvidersStatus | undefined, + value: string, +): DaemonWorkspaceProvidersStatus | undefined { + if (!status) return undefined; + let changed = false; + const providers = status.providers.map((provider) => { + let providerChanged = false; + const models = provider.models.map((model) => { + const configOptions = patchReasoningConfigOptions( + model.configOptions, + value, + ); + if (configOptions === model.configOptions) return model; + changed = true; + providerChanged = true; + return { + ...model, + configOptions: configOptions as typeof model.configOptions, + }; + }); + return providerChanged ? { ...provider, models } : provider; + }); + return changed ? { ...status, providers } : status; +} + export function mapSupportedCommands( status: DaemonSessionSupportedCommandsStatus | undefined, ): { @@ -318,6 +418,29 @@ export function updateConnectionFromDaemonEvent( skills, })); } + if (getString(update, 'sessionUpdate') === 'config_option_update') { + const configOptions = update?.['configOptions']; + if (!Array.isArray(configOptions)) return; + setConnection((current) => { + const reasoning = mapReasoningControls( + configOptions, + current.reasoning?.effort, + ); + return { + ...current, + reasoning, + context: current.context + ? { + ...current.context, + state: { + ...current.context.state, + configOptions, + }, + } + : current.context, + }; + }); + } return; } @@ -376,6 +499,48 @@ export function updateConnectionFromDaemonEvent( } break; } + case 'settings_changed': { + const data = getRecord(event.data); + const value = data?.['value']; + if ( + data?.['key'] === 'model.reasoningEffort' && + typeof value === 'string' + ) { + setConnection((current) => { + const providers = patchProviderReasoningPreviews( + current.providers, + value, + ); + if (providers && providers !== current.providers) { + if (current.sessionId) return { ...current, providers }; + const projected = mapProviderStatus(providers); + return { + ...current, + providers, + models: projected.models, + currentModel: projected.currentModel ?? current.currentModel, + contextWindow: projected.contextWindow ?? current.contextWindow, + }; + } + if (current.sessionId || !current.models) return current; + return { + ...current, + models: current.models.map((model) => + model.reasoningPreview + ? { + ...model, + reasoningPreview: patchReasoningControls( + model.reasoningPreview, + value, + ), + } + : model, + ), + }; + }); + } + break; + } default: break; } diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 5dbe4f73f90..c9eaa517c84 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -117,11 +117,16 @@ export interface DaemonConnectionState { export interface DaemonReasoningControls { enabled: boolean; effort: string; - efforts: string[]; + efforts: DaemonReasoningEffortOption[]; /** Defaults to true. False means effort is mutable but thinking is required. */ canDisable?: boolean; } +export interface DaemonReasoningEffortOption { + value: string; + name: string; +} + export interface DaemonTokenUsage { inputTokens?: number; outputTokens?: number; @@ -557,6 +562,7 @@ export interface DaemonWorkspaceEventSignals { agentsVersion: number; toolsVersion: number; settingsVersion: number; + reasoningSettingsVersion: number; skillsVersion: number; lastSkillMutation?: DaemonSkillToggleMutation; skillMutationsByCwd?: Record;