diff --git a/docs/design/webshell-qwen38-reasoning-config.md b/docs/design/webshell-qwen38-reasoning-config.md index 76a83d0db04..3988600af26 100644 --- a/docs/design/webshell-qwen38-reasoning-config.md +++ b/docs/design/webshell-qwen38-reasoning-config.md @@ -63,11 +63,30 @@ Reading the manifest alone does not inject a default into generation configuration, so sessions that never change the controls retain main's existing wire behavior. -If the live session already carries a generic effort outside the manifest -(`high` or `max`), ACP preserves that value through its existing generic -option and WebShell hides the model-specific controls. This avoids displaying -an inaccurate tier or changing live configuration merely by opening the -popover. +If the live session inherits a generic effort outside the manifest (`high` or +`max`), ACP projects its documented effective alias, `xhigh`, through the +registered model-specific choices. The controls stay available instead of +disappearing after session creation. DashScope likewise maps `minimal` to +`low`; a static `thinking_budget` maps to `low` at 0–4096 tokens, `medium` at +4097–16384, and `xhigh` at 16385–262144, following the +[OpenAI-compatible Qwen API](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions). + +If a static DashScope thinking field currently overrides the unified setting, +ACP projects its effective off state or effort alias through the same controls. +Selecting a model-specific value removes only the competing thinking fields +from copied request-parameter maps, preserves both the original shared maps +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 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 +generic option without `none` remains incompatible and hidden. Workspace +previews resolve this constraint from the selected provider-model entry, so the +same behavior is available before lazy session creation. A stale welcome +Thinking-off intent is discarded if refreshed model metadata makes thinking +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`, @@ -84,6 +103,7 @@ Included: prompt; - authoritative replacement by same-session context; - the current WebShell conversation; +- 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, and the existing live mutation behavior. diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index c9dc05d49fe..0d706011983 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -4043,9 +4043,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getModes: vi.fn().mockReturnValue([]), getApprovalMode: vi.fn().mockReturnValue('default'), getReasoningEffort: vi.fn().mockReturnValue(undefined), + getReasoningEffortOverride: vi.fn().mockReturnValue(undefined), setReasoningEffort: vi.fn(), getSessionId: vi.fn().mockReturnValue('test-session-id'), getAuthType: vi.fn().mockReturnValue('api-key'), + getCurrentModelRegistryBaseUrl: vi.fn().mockReturnValue(undefined), getAllConfiguredModels: vi.fn().mockReturnValue([]), getGeminiClient: vi.fn().mockReturnValue({ isInitialized: vi.fn().mockReturnValue(true), @@ -7646,6 +7648,11 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getAuthType: vi.fn().mockReturnValue('qwen'), getActiveRuntimeModelSnapshot: vi.fn().mockReturnValue(undefined), getModel: vi.fn().mockReturnValue('qwen3.8-max'), + getResolvedModelConfig: vi.fn((authType: string, modelId: string) => + authType === 'qwen' && modelId === 'qwen3.8-max' + ? { generationConfig: { thinkingMandatory: true } } + : undefined, + ), getAllConfiguredModels: vi.fn().mockReturnValue([ { id: 'qwen3.8-max', @@ -7714,12 +7721,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => { { id: 'reasoning_effort', currentValue: 'xhigh', - options: [ - { value: 'none' }, - { value: 'low' }, - { value: 'medium' }, - { value: 'xhigh' }, - ], + options: [{ value: 'low' }, { value: 'medium' }, { value: 'xhigh' }], + _meta: { + 'qwenCode/reasoning': { + defaultEffort: 'xhigh', + thinkingMandatory: true, + }, + }, }, ]); expect( @@ -7943,19 +7951,6 @@ describe('QwenAgent MCP SSE/HTTP support', () => { session.configOptions.filter((item) => item.id === 'effort'), ).toEqual([]); expect(option).toMatchObject({ - currentValue: 'high', - }); - expect(option?.options.map(({ value }) => value)).not.toContain('none'); - - 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' }, @@ -8014,6 +8009,291 @@ describe('QwenAgent MCP SSE/HTTP support', () => { } }); + 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 }; + 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.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 SetSessionConfigOptionResponse; + expect( + session.configOptions.find((item) => item.id === 'reasoning_effort'), + ).toMatchObject({ + currentValue: 'none', + options: [ + { value: 'none' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'xhigh' }, + ], + }); + + const selected = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'medium', + })) as SetSessionConfigOptionResponse; + expect(generation.samplingParams).toEqual({}); + expect(generation.reasoning).toEqual({ effort: 'medium' }); + expect( + selected.configOptions.find((item) => item.id === 'reasoning_effort') + ?.currentValue, + ).toBe('medium'); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it.each([ + [false, 'none'], + [true, 'medium'], + ] as const)( + 'projects a static budget over unified thinking disable with mandatory=%s as %s', + async (thinkingMandatory, expectedValue) => { + const sessionId = `qwen38-disabled-budget-${thinkingMandatory}`; + const innerConfig = await setupSessionMocks(sessionId); + const generation: { + thinkingMandatory?: true; + reasoning: false; + samplingParams: Record; + } = { + ...(thinkingMandatory ? { thinkingMandatory: true as const } : {}), + reasoning: false, + samplingParams: { thinking_budget: 4097 }, + }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn().mockReturnValue(undefined); + innerConfig.getReasoningEffortOverride = vi.fn(() => ({ + source: 'samplingParams' as const, + field: 'thinking_budget' as const, + })); + + 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') + ?.currentValue, + ).toBe(expectedValue); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }, + ); + + it('re-enables qwen3.8-max after a static thinking disable', async () => { + const sessionId = 'qwen38-static-thinking-disable'; + const innerConfig = await setupSessionMocks(sessionId); + const extraBody = { enable_thinking: false, seed: 7 }; + const samplingParams = { thinking_budget: 2048, temperature: 0.2 }; + const generation: { + reasoning?: false | { effort?: string }; + extra_body?: Record; + samplingParams?: Record; + } = { + reasoning: { effort: 'low' }, + extra_body: extraBody, + samplingParams, + }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn(() => + generation.reasoning ? generation.reasoning.effort : undefined, + ); + innerConfig.getReasoningEffortOverride = vi.fn(() => + generation.extra_body?.['enable_thinking'] === false + ? { + source: 'extra_body' as const, + field: 'enable_thinking' as const, + } + : generation.samplingParams?.['thinking_budget'] !== undefined + ? { + source: 'samplingParams' as const, + field: 'thinking_budget' as const, + } + : undefined, + ); + + 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' }, + ], + }); + + const selected = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'xhigh', + })) as SetSessionConfigOptionResponse; + expect(generation.extra_body).toEqual({ seed: 7 }); + expect(generation.samplingParams).toEqual({ temperature: 0.2 }); + expect(generation.extra_body).not.toBe(extraBody); + expect(generation.samplingParams).not.toBe(samplingParams); + expect(extraBody).toEqual({ enable_thinking: false, seed: 7 }); + expect(samplingParams).toEqual({ + thinking_budget: 2048, + temperature: 0.2, + }); + expect(generation.reasoning).toEqual({ effort: 'xhigh' }); + expect( + selected.configOptions.find((item) => item.id === 'reasoning_effort') + ?.currentValue, + ).toBe('xhigh'); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it.each([ + [4096, 'low'], + [4097, 'medium'], + [16384, 'medium'], + [16385, 'xhigh'], + ] as const)( + 'projects a static qwen3.8-max thinking budget of %i as %s', + async (thinkingBudget, expectedEffort) => { + const sessionId = `qwen38-static-thinking-budget-${thinkingBudget}`; + const innerConfig = await setupSessionMocks(sessionId); + const generation: { + reasoning?: false | { effort?: string }; + samplingParams?: Record; + } = { + samplingParams: { thinking_budget: thinkingBudget }, + }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn(() => + generation.reasoning ? generation.reasoning.effort : undefined, + ); + innerConfig.getReasoningEffortOverride = vi.fn(() => ({ + source: 'samplingParams' as const, + field: 'thinking_budget' as const, + })); + + 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') + ?.currentValue, + ).toBe(expectedEffort); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }, + ); + + it('does not project qwen3.8 controls onto an opaque model route', async () => { + const sessionId = 'qwen38-opaque-route-reasoning'; + const innerConfig = await setupSessionMocks(sessionId); + let currentEffort: string | undefined; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getAuthType = vi.fn().mockReturnValue('openai'); + innerConfig.getCurrentModelRegistryBaseUrl = vi + .fn() + .mockReturnValue('https://one.example/v1'); + innerConfig.getAllConfiguredModels = vi.fn().mockReturnValue([ + { + id: 'qwen3.8-max', + label: 'Qwen 3.8 Max One', + authType: 'openai', + baseUrl: 'https://one.example/v1', + registryBaseUrl: 'https://one.example/v1', + }, + { + id: 'qwen3.8-max', + label: 'Qwen 3.8 Max Two', + authType: 'openai', + baseUrl: 'https://two.example/v1', + registryBaseUrl: 'https://two.example/v1', + }, + ]); + innerConfig.getReasoningEffort = vi.fn(() => currentEffort); + innerConfig.setReasoningEffort = vi.fn((effort: string | undefined) => { + currentEffort = effort; + }); + + const { agent, agentPromise } = await bootAcpAgent(); + try { + const session = (await agent.newSession({ + cwd: '/tmp', + mcpServers: [], + })) as SetSessionConfigOptionResponse; + expect( + session.configOptions.find((item) => item.id === 'model')?.currentValue, + ).toMatch(/^qwen-route:v1:/); + expect( + session.configOptions.find((item) => item.id === 'reasoning_effort'), + ).toMatchObject({ + currentValue: 'default', + options: [ + { value: 'default' }, + { value: 'low' }, + { value: 'medium' }, + { value: 'high' }, + { value: 'xhigh' }, + { value: 'max' }, + ], + }); + + await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'medium', + }); + expect(innerConfig.setReasoningEffort).toHaveBeenCalledWith('medium'); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + it('projects toggle-only Qwen reasoning without effort tiers', async () => { const sessionId = 'qwen37-toggle-reasoning-session'; const innerConfig = await setupSessionMocks(sessionId); @@ -8087,7 +8367,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { } }); - it('hides qwen3.8-max reasoning controls when thinking is mandatory', async () => { + it('keeps qwen3.8-max effort controls when thinking is mandatory', async () => { const sessionId = 'qwen38-mandatory-thinking-session'; const innerConfig = await setupSessionMocks(sessionId); const generation = { @@ -8113,8 +8393,27 @@ describe('QwenAgent MCP SSE/HTTP support', () => { const option = session.configOptions.find( (item) => item.id === 'reasoning_effort', ); - expect(option?.currentValue).toBe('medium'); - expect(option?.options.map(({ value }) => value)).not.toContain('none'); + expect(option).toMatchObject({ + currentValue: 'medium', + options: [{ value: 'low' }, { value: 'medium' }, { value: 'xhigh' }], + _meta: { + 'qwenCode/reasoning': { + defaultEffort: 'xhigh', + thinkingMandatory: true, + }, + }, + }); + + const selected = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'xhigh', + })) as SetSessionConfigOptionResponse; + expect(generation.reasoning).toEqual({ effort: 'xhigh' }); + expect( + selected.configOptions.find((item) => item.id === 'reasoning_effort') + ?.currentValue, + ).toBe('xhigh'); await expect( agent.setSessionConfigOption({ @@ -8123,9 +8422,81 @@ describe('QwenAgent MCP SSE/HTTP support', () => { value: 'none', }), ).rejects.toThrow( - 'Unknown reasoning effort: none. Choose one of: default, low, medium, high, xhigh, max', + 'Unknown reasoning effort: none. Choose one of: low, medium, xhigh', ); - expect(generation.reasoning).toEqual({ effort: 'medium' }); + expect(generation.reasoning).toEqual({ effort: 'xhigh' }); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('reports the model default when mandatory thinking strips a static disable', async () => { + const sessionId = 'qwen38-mandatory-static-disable-session'; + const innerConfig = await setupSessionMocks(sessionId); + const generation: { + thinkingMandatory: true; + reasoning: { effort: string }; + extra_body: Record; + } = { + thinkingMandatory: true, + reasoning: { effort: 'low' }, + extra_body: { enable_thinking: false }, + }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn(() => generation.reasoning.effort); + innerConfig.getReasoningEffortOverride = vi.fn(() => ({ + source: 'extra_body' as const, + field: 'enable_thinking' as const, + })); + + 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') + ?.currentValue, + ).toBe('xhigh'); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + + it('reports the model default when mandatory thinking strips a static effort while disabled', async () => { + const sessionId = 'qwen38-mandatory-disabled-static-effort-session'; + const innerConfig = await setupSessionMocks(sessionId); + const generation: { + thinkingMandatory: true; + reasoning: false; + samplingParams: Record; + } = { + thinkingMandatory: true, + reasoning: false, + samplingParams: { reasoning_effort: 'low' }, + }; + innerConfig.getModel = vi.fn().mockReturnValue('qwen3.8-max'); + innerConfig.getContentGeneratorConfig = vi.fn(() => generation); + innerConfig.getReasoningEffort = vi.fn().mockReturnValue(undefined); + innerConfig.getReasoningEffortOverride = vi.fn(() => ({ + source: 'samplingParams' as const, + field: 'reasoning_effort' as const, + })); + + 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') + ?.currentValue, + ).toBe('xhigh'); } finally { mockConnectionState.resolve(); await agentPromise; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 1b705fa92d9..bb33b7a7e5e 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -5612,6 +5612,8 @@ class QwenAgent implements Agent { break; } case 'reasoning_effort': { + const generation = session.getConfig().getContentGeneratorConfig(); + const thinkingMandatory = generation.thinkingMandatory === true; const modelReasoning = this.getModelReasoningConfiguration( session.getConfig(), ); @@ -5620,7 +5622,7 @@ class QwenAgent implements Agent { ? undefined : modelReasoning.efforts; const selected = - value === REASONING_EFFORT_NONE + value === REASONING_EFFORT_NONE && !thinkingMandatory ? REASONING_EFFORT_NONE : modelReasoning.toggleOnly ? value === REASONING_EFFORT_DEFAULT @@ -5629,7 +5631,7 @@ class QwenAgent implements Agent { : effortValues?.find((effort) => effort === value); if (!selected) { const choices = [ - REASONING_EFFORT_NONE, + ...(thinkingMandatory ? [] : [REASONING_EFFORT_NONE]), ...(effortValues ?? [REASONING_EFFORT_DEFAULT]), ]; throw RequestError.invalidParams( @@ -5637,7 +5639,17 @@ class QwenAgent implements Agent { `Unknown reasoning effort: ${value}. Choose one of: ${choices.join(', ')}`, ); } - const generation = session.getConfig().getContentGeneratorConfig(); + 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) { @@ -6909,7 +6921,14 @@ class QwenAgent implements Agent { const configOptions = model.isRuntimeModel || modelId.startsWith(ACP_ROUTE_ID_PREFIX) ? undefined - : buildModelReasoningConfigPreview(model.id); + : buildModelReasoningConfigPreview(model.id, { + thinkingMandatory: + config.getResolvedModelConfig?.( + model.authType, + model.id, + model.registryBaseUrl ?? model.baseUrl, + )?.generationConfig.thinkingMandatory === true, + }); const providerModel: ServeWorkspaceProviderModel = { modelId, baseModelId: parseAcpBaseModelId(effectiveModelId), @@ -12971,12 +12990,67 @@ class QwenAgent implements Agent { options: configModelOptions, }; - const modelReasoning = this.getModelReasoningConfiguration(config); + const generation = config.getContentGeneratorConfig(); + const modelReasoning = this.getModelReasoningConfiguration( + config, + currentModelId, + ); const currentModelEffort = config.getReasoningEffort?.(); + const reasoningOverride = config.getReasoningEffortOverride?.(); + const reasoningOverrideValue = reasoningOverride + ? generation[reasoningOverride.source]?.[reasoningOverride.field] + : undefined; + const normalizedEffortOverride = + reasoningOverride?.field === 'reasoning_effort' && + typeof reasoningOverrideValue === 'string' + ? reasoningOverrideValue === 'minimal' + ? 'low' + : REASONING_EFFORT_TIERS.find( + (effort) => effort === reasoningOverrideValue, + ) + : undefined; + const normalizedBudgetOverride = + reasoningOverride?.field === 'thinking_budget' && + typeof reasoningOverrideValue === 'number' && + Number.isFinite(reasoningOverrideValue) && + reasoningOverrideValue >= 0 && + reasoningOverrideValue <= 262_144 + ? reasoningOverrideValue <= 4_096 + ? 'low' + : reasoningOverrideValue <= 16_384 + ? 'medium' + : 'xhigh' + : undefined; + const normalizedOverrideEffort = + normalizedEffortOverride ?? normalizedBudgetOverride; + const overrideDisablesReasoning = + (reasoningOverride?.field === 'enable_thinking' && + reasoningOverrideValue === false) || + (reasoningOverride?.field === 'reasoning_effort' && + reasoningOverrideValue === REASONING_EFFORT_NONE); + const mandatoryUsesDefaultEffort = + generation.thinkingMandatory === true && + (overrideDisablesReasoning || + (generation.reasoning === false && + reasoningOverride?.field === 'reasoning_effort')); + const effectiveModelEffort = + modelReasoning && !modelReasoning.toggleOnly + ? mandatoryUsesDefaultEffort + ? modelReasoning.defaultEffort + : normalizedOverrideEffort + ? (modelReasoning.efforts.find( + (effort) => effort === normalizedOverrideEffort, + ) ?? modelReasoning.defaultEffort) + : currentModelEffort + : currentModelEffort; + const reasoningEnabled = + generation.reasoning !== false && + (!reasoningOverride || !overrideDisablesReasoning); const reasoningEffortConfigOption: SessionConfigOption = (modelReasoning ? buildModelReasoningConfigOption(rawCurrentModelId, { - enabled: config.getContentGeneratorConfig().reasoning !== false, - effort: currentModelEffort, + enabled: reasoningEnabled, + effort: effectiveModelEffort, + thinkingMandatory: generation.thinkingMandatory === true, }) : undefined) ?? { id: 'reasoning_effort', @@ -13005,22 +13079,24 @@ class QwenAgent implements Agent { private getModelReasoningConfiguration( config: Config, + currentAcpModelId?: string, ): ModelReasoningConfiguration | undefined { - if ( - config.getActiveRuntimeModelSnapshot?.() || - config.getReasoningEffortOverride?.() || - config.getContentGeneratorConfig().thinkingMandatory === true - ) { + if (config.getActiveRuntimeModelSnapshot?.()) { + return undefined; + } + const completeModelId = + currentAcpModelId ?? + getCurrentAcpModelId( + this.buildSelectableModelOptions(config), + (config.getModel() || '').trim(), + config.getAuthType?.(), + config.getCurrentModelRegistryBaseUrl?.(), + ); + if (completeModelId.startsWith(ACP_ROUTE_ID_PREFIX)) { return undefined; } const reasoning = getModelConfiguration(config.getModel())?.reasoning; - const currentEffort = config.getReasoningEffort?.(); - return reasoning?.thinking && - (reasoning.toggleOnly || - !currentEffort || - reasoning.efforts.includes(currentEffort)) - ? reasoning - : undefined; + return reasoning?.thinking ? reasoning : undefined; } private buildSelectableModelOptions(config: Config) { diff --git a/packages/cli/src/acp-integration/model-configuration.test.ts b/packages/cli/src/acp-integration/model-configuration.test.ts index ca1e463cb9f..42ab0b4dfcc 100644 --- a/packages/cli/src/acp-integration/model-configuration.test.ts +++ b/packages/cli/src/acp-integration/model-configuration.test.ts @@ -38,6 +38,32 @@ describe('model configuration manifest', () => { }); }); + it('omits Thinking off when qwen3.8-max requires thinking', () => { + expect( + buildModelReasoningConfigOption('qwen3.8-max', { + thinkingMandatory: true, + }), + ).toMatchObject({ + currentValue: 'xhigh', + options: [{ value: 'low' }, { value: 'medium' }, { value: 'xhigh' }], + _meta: { + 'qwenCode/reasoning': { + defaultEffort: 'xhigh', + thinkingMandatory: true, + }, + }, + }); + }); + + it.each(['high', 'max'] as const)( + 'presents inherited %s as the qwen3.8-max xhigh alias', + (effort) => { + expect( + buildModelReasoningConfigOption('qwen3.8-max', { effort }), + ).toMatchObject({ currentValue: 'xhigh' }); + }, + ); + it.each([ undefined, 'qwen3.7-plus', @@ -56,6 +82,18 @@ describe('model configuration manifest', () => { ]); }); + it('preserves mandatory thinking in the workspace preview', () => { + expect( + buildModelReasoningConfigPreview('qwen3.8-max', { + thinkingMandatory: true, + }), + ).toEqual([ + buildModelReasoningConfigOption('qwen3.8-max', { + thinkingMandatory: true, + }), + ]); + }); + 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 adb0e122c92..23dbb61ce6b 100644 --- a/packages/cli/src/acp-integration/model-configuration.ts +++ b/packages/cli/src/acp-integration/model-configuration.ts @@ -57,6 +57,12 @@ export const REASONING_EFFORT_NAMES: Record = { max: 'Max', }; +type ModelReasoningConfigState = { + enabled?: boolean; + effort?: ReasoningEffort; + thinkingMandatory?: boolean; +}; + export function getModelConfiguration(modelId: string | undefined): | { readonly reasoning?: ModelReasoningConfiguration; @@ -67,13 +73,14 @@ export function getModelConfiguration(modelId: string | undefined): export function buildModelReasoningConfigOption( modelId: string | undefined, - state: { enabled?: boolean; effort?: ReasoningEffort } = {}, + state: ModelReasoningConfigState = {}, ): SessionConfigOption | undefined { const reasoning = getModelConfiguration(modelId)?.reasoning; if (!reasoning?.thinking) return undefined; + const thinkingMandatory = state.thinkingMandatory === true; const currentValue = - state.enabled === false + state.enabled === false && !thinkingMandatory ? REASONING_EFFORT_NONE : reasoning.toggleOnly ? REASONING_EFFORT_DEFAULT @@ -88,11 +95,15 @@ export function buildModelReasoningConfigOption( type: 'select', currentValue, options: [ - { - value: REASONING_EFFORT_NONE, - name: 'Thinking off', - description: 'Disable thinking for this session', - }, + ...(thinkingMandatory + ? [] + : [ + { + value: REASONING_EFFORT_NONE, + name: 'Thinking off', + description: 'Disable thinking for this session', + }, + ]), ...(reasoning.toggleOnly ? [ { @@ -109,17 +120,24 @@ export function buildModelReasoningConfigOption( ], _meta: { 'qwenCode/reasoning': reasoning.toggleOnly - ? { toggleOnly: true } - : { defaultEffort: reasoning.defaultEffort }, + ? { + toggleOnly: true, + ...(thinkingMandatory ? { thinkingMandatory: true } : {}), + } + : { + defaultEffort: reasoning.defaultEffort, + ...(thinkingMandatory ? { thinkingMandatory: true } : {}), + }, }, }; } export function buildModelReasoningConfigPreview( modelId: string | undefined, + state: ModelReasoningConfigState = {}, ): SessionConfigOption[] | undefined { const reasoning = getModelConfiguration(modelId)?.reasoning; if (!reasoning?.thinking || reasoning.toggleOnly) return undefined; - const option = buildModelReasoningConfigOption(modelId); + const option = buildModelReasoningConfigOption(modelId, state); return option ? [option] : undefined; } diff --git a/packages/cli/src/serve/workspace-providers-status.test.ts b/packages/cli/src/serve/workspace-providers-status.test.ts index 65f16f25bc2..1658ca5deed 100644 --- a/packages/cli/src/serve/workspace-providers-status.test.ts +++ b/packages/cli/src/serve/workspace-providers-status.test.ts @@ -280,7 +280,11 @@ describe('createWorkspaceProvidersStatusProvider', () => { model: { name: 'qwen3.8-max' }, modelProviders: { openai: [ - { id: 'qwen3.8-max', name: 'Qwen 3.8 Max' }, + { + id: 'qwen3.8-max', + name: 'Qwen 3.8 Max', + generationConfig: { thinkingMandatory: true }, + }, { id: 'qwen3.8-max-preview', name: 'Qwen 3.8 Max Preview' }, { id: 'qwen3.8-max-latest', name: 'Qwen 3.8 Max Alias' }, { id: 'qwen-plus', name: 'Qwen Plus' }, @@ -296,12 +300,13 @@ describe('createWorkspaceProvidersStatusProvider', () => { { id: 'reasoning_effort', currentValue: 'xhigh', - options: [ - { value: 'none' }, - { value: 'low' }, - { value: 'medium' }, - { value: 'xhigh' }, - ], + options: [{ value: 'low' }, { value: 'medium' }, { value: 'xhigh' }], + _meta: { + 'qwenCode/reasoning': { + defaultEffort: 'xhigh', + thinkingMandatory: true, + }, + }, }, ]); expect( diff --git a/packages/cli/src/serve/workspace-providers-status.ts b/packages/cli/src/serve/workspace-providers-status.ts index c0d56d3b6f2..56425c42c91 100644 --- a/packages/cli/src/serve/workspace-providers-status.ts +++ b/packages/cli/src/serve/workspace-providers-status.ts @@ -165,7 +165,14 @@ function buildWorkspaceProvidersStatus( currentAuth === model.authType && currentAcpModelId === modelId; const configOptions = modelId.startsWith(ACP_ROUTE_ID_PREFIX) ? undefined - : buildModelReasoningConfigPreview(model.id); + : buildModelReasoningConfigPreview(model.id, { + thinkingMandatory: + modelsConfig.getResolvedModel( + model.authType, + model.id, + model.registryBaseUrl ?? model.baseUrl, + )?.generationConfig.thinkingMandatory === true, + }); const providerModel: ServeWorkspaceProviderModel = { modelId, baseModelId: parseAcpBaseModelId(effectiveModelId), diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index d81de1c346a..ca33ea28e7d 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -41,7 +41,17 @@ type MockConnection = { workspaceCwd: string; currentModel: string; currentMode: string; - models: Array<{ id: string; label?: string }>; + models: Array<{ + id: string; + label?: string; + reasoningPreview?: { + enabled: boolean; + effort: string; + efforts: string[]; + defaultEffort: string; + canDisable?: boolean; + }; + }>; commands: unknown[]; skills: string[] | undefined; capabilities: { qwenCodeVersion: string; features: string[] }; @@ -98,6 +108,14 @@ type ChatEditorTestProps = { disabled?: boolean; dialogOpen?: boolean; onToggleShortcuts?: () => void; + reasoning?: { + enabled: boolean; + effort: string; + efforts: string[]; + defaultEffort: string; + canDisable?: boolean; + }; + onSelectReasoningEffort?: (value: string) => Promise | void; voiceTarget?: VoiceWorkspaceTarget; voiceStatusRevision?: VoiceStatusRevision; placeholderText?: string; @@ -262,6 +280,7 @@ const { }), refreshCommands: vi.fn().mockResolvedValue(undefined), setModel: vi.fn().mockResolvedValue(undefined), + setReasoningEffort: vi.fn().mockResolvedValue(undefined), setApprovalMode: vi.fn().mockResolvedValue(undefined), getRewindSnapshots: vi.fn().mockResolvedValue([]), rewindSession: vi.fn().mockResolvedValue(undefined), @@ -5027,6 +5046,7 @@ beforeEach(() => { mockConnection.displayName = 'Session One'; mockConnection.currentMode = 'default'; mockConnection.currentModel = 'qwen'; + mockConnection.models = [{ id: 'qwen', label: 'Qwen' }]; mockConnection.error = undefined; mockConnection.errorStatus = undefined; mockConnection.missingSession = false; @@ -5186,6 +5206,7 @@ beforeEach(() => { mockSessionActions.reloadSession.mockResolvedValue(undefined); mockSessionActions.refreshCommands.mockResolvedValue(undefined); mockSessionActions.setModel.mockResolvedValue(undefined); + mockSessionActions.setReasoningEffort.mockResolvedValue(undefined); mockSessionActions.setApprovalMode.mockResolvedValue(undefined); mockSessionActions.getRewindSnapshots.mockResolvedValue([]); mockSessionActions.rewindSession.mockResolvedValue(undefined); @@ -14117,6 +14138,76 @@ describe('App session callbacks', () => { ).not.toBeNull(); }); + it('does not restore a stale welcome disable after mandatory reasoning clears it', async () => { + const reasoningPreview = (canDisable: boolean) => ({ + enabled: true, + effort: 'xhigh', + efforts: ['low', 'medium', 'xhigh'], + defaultEffort: 'xhigh', + canDisable, + }); + mockConnection.sessionId = undefined; + mockConnection.workspaceCwd = '/workspace'; + mockConnection.currentModel = 'qwen3.8-max'; + mockConnection.models = [ + { + id: 'qwen3.8-max', + label: 'qwen3.8-max', + reasoningPreview: reasoningPreview(true), + }, + ]; + mockSessionActions.createSession.mockImplementation(async () => { + mockConnection.sessionId = 'session-created'; + return { sessionId: 'session-created' }; + }); + + const { rerender } = renderApp(); + await flush(); + act(() => { + testState.latestChatEditorProps?.onSelectReasoningEffort?.('none'); + }); + await flush(); + expect(testState.latestChatEditorProps?.reasoning?.enabled).toBe(false); + + mockConnection.models = [ + { + id: 'qwen3.8-max', + label: 'qwen3.8-max', + reasoningPreview: reasoningPreview(false), + }, + ]; + rerender(); + await flush(); + expect(testState.latestChatEditorProps?.reasoning).toMatchObject({ + enabled: true, + canDisable: false, + effort: 'xhigh', + }); + + mockConnection.models = [ + { + id: 'qwen3.8-max', + label: 'qwen3.8-max', + reasoningPreview: reasoningPreview(true), + }, + ]; + rerender(); + await flush(); + expect(testState.latestChatEditorProps?.reasoning).toMatchObject({ + enabled: true, + canDisable: true, + effort: 'xhigh', + }); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('first prompt'); + await vi.waitFor(() => { + expect(mockSessionActions.sendPrompt).toHaveBeenCalledOnce(); + }); + }); + expect(mockSessionActions.setReasoningEffort).not.toHaveBeenCalled(); + }); + it('commits the first prompt after creating its session', async () => { mockConnection.sessionId = undefined; mockSessionActions.createSession.mockImplementation(async () => { diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index bf793528573..cf21a477679 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -6026,8 +6026,9 @@ export function App({ reasoningIntent && reasoningIntent.modelId === modelId && reasoningPreview && - (reasoningIntent.value === 'none' || - reasoningPreview.efforts.includes(reasoningIntent.value)) + (reasoningIntent.value === 'none' + ? reasoningPreview.canDisable !== false + : reasoningPreview.efforts.includes(reasoningIntent.value)) ? reasoningIntent.value : undefined; const modeId = @@ -6543,11 +6544,26 @@ export function App({ ? connection.models?.find((model) => model.id === currentModel) ?.reasoningPreview : undefined; + useEffect(() => { + if ( + pendingReasoningIntent?.modelId === currentModel && + pendingReasoningIntent.value === 'none' && + welcomeReasoningPreview?.canDisable === false + ) { + setPendingReasoningIntent(undefined); + } + }, [ + currentModel, + pendingReasoningIntent, + setPendingReasoningIntent, + welcomeReasoningPreview, + ]); const validPendingReasoningIntent = pendingReasoningIntent?.modelId === currentModel && welcomeReasoningPreview && - (pendingReasoningIntent.value === 'none' || - welcomeReasoningPreview.efforts.includes(pendingReasoningIntent.value)) + (pendingReasoningIntent.value === 'none' + ? welcomeReasoningPreview.canDisable !== false + : welcomeReasoningPreview.efforts.includes(pendingReasoningIntent.value)) ? pendingReasoningIntent : undefined; const displayedReasoning = hasAuthoritativeReasoningContext @@ -11263,6 +11279,7 @@ export function App({ if (!preview) return; const previous = pendingReasoningIntentRef.current; if (value === 'none') { + if (preview.canDisable === false) return; const previousEffort = previous?.modelId === modelId && preview.efforts.includes(previous.effort) diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 916c8fb69cb..9283e5bef0d 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -1083,7 +1083,7 @@ function ModelReasoningControls({ {t('reasoning.thinking')} 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 ef8272a125a..9184c779669 100644 --- a/packages/web-shell/client/e2e/web-shell.smoke.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.smoke.spec.ts @@ -45,6 +45,26 @@ const qwen38ReasoningConfigOptions = (currentValue = 'xhigh') => [ }, ]; +const qwen38MandatoryReasoningConfigOptions = (currentValue = 'xhigh') => [ + { + id: 'reasoning_effort', + name: 'Reasoning effort', + type: 'select', + currentValue, + options: [ + { value: 'low', name: 'Low' }, + { value: 'medium', name: 'Medium' }, + { value: 'xhigh', name: 'Extra high' }, + ], + _meta: { + 'qwenCode/reasoning': { + defaultEffort: 'xhigh', + thinkingMandatory: true, + }, + }, + }, +]; + test('loads replayed transcript and connects to fake daemon @smoke', async ({ page, }, testInfo) => { @@ -277,6 +297,57 @@ test('configures qwen3.8-max reasoning from the model popover @smoke', async ({ ).toBeVisible(); }); +test('keeps mandatory qwen3.8-max effort switchable after messages and while running @smoke', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario({ + currentModel: 'qwen3.8-max', + events: [ + userTextEvent('Completed question', { id: 1 }), + assistantTextEvent('Completed answer', { id: 2 }), + turnCompleteEvent('completed-prompt', { id: 3 }), + ], + state: { + configOptions: qwen38MandatoryReasoningConfigOptions(), + }, + }); + const daemon = await installScenario(page, scenario, testInfo); + await gotoSession(page, scenario, daemon); + + await expect(page.locator('[data-web-shell-message-list]')).toContainText( + 'Completed answer', + ); + const modelButton = page.locator('[data-web-shell-model-button]'); + await modelButton.click(); + const controls = page.locator('[data-web-shell-model-reasoning]'); + const thinking = controls.locator('[data-web-shell-thinking-toggle]'); + const medium = controls.locator('[data-web-shell-effort="medium"]'); + await expect(controls).toBeVisible(); + await expect(thinking).toBeChecked(); + await expect(thinking).toBeDisabled(); + await expect(medium).toBeEnabled(); + await medium.click(); + await expect.poll(() => daemon.configOptionRequests().length).toBe(1); + expect( + requestBodyRecord(firstRequest(daemon.configOptionRequests())), + ).toEqual({ configId: 'reasoning_effort', value: 'medium' }); + + await page.keyboard.press('Escape'); + await fillComposer(page, 'Keep switching while this prompt runs'); + await page.locator('[data-web-shell-composer-submit]').click(); + await expect.poll(() => daemon.promptRequests().length).toBe(1); + await modelButton.click(); + const low = controls.locator('[data-web-shell-effort="low"]'); + await expect(low).toBeEnabled(); + await low.click(); + await expect.poll(() => daemon.configOptionRequests().length).toBe(2); + expect(requestBodyRecord(daemon.configOptionRequests()[1]!)).toEqual({ + configId: 'reasoning_effort', + value: 'low', + }); + await expect(modelButton).toContainText('Low'); +}); + test('previews qwen3.8-max reasoning before lazy session creation @smoke', async ({ page, }, testInfo) => { @@ -464,6 +535,72 @@ test('previews qwen3.8-max reasoning before lazy session creation @smoke', async ).toHaveAttribute('aria-pressed', 'true'); }); +test('keeps mandatory qwen3.8-max effort switchable before lazy session creation @smoke', async ({ + page, +}, testInfo) => { + const stableModel = { + modelId: 'qwen3.8-max', + baseModelId: 'qwen3.8-max', + name: 'qwen3.8-max', + contextLimit: 131_072, + isCurrent: true, + isRuntime: false, + configOptions: qwen38MandatoryReasoningConfigOptions(), + }; + const scenario = createWebShellDaemonScenario({ + currentModel: 'qwen3.8-max', + state: { + configOptions: qwen38MandatoryReasoningConfigOptions(), + models: { + currentModelId: 'qwen3.8-max', + availableModels: [ + { + modelId: 'qwen3.8-max', + baseModelId: 'qwen3.8-max', + name: 'qwen3.8-max', + contextLimit: 131_072, + }, + ], + }, + }, + providers: { + providers: [ + { + kind: 'model_provider', + status: 'ok', + authType: 'qwen-oauth', + current: true, + models: [stableModel], + }, + ], + }, + }); + const daemon = await installScenario(page, scenario, testInfo); + await gotoEmptyMobileWelcomeHarness(page); + + const modelButton = page.locator('[data-web-shell-model-button]'); + await modelButton.click(); + const thinking = page.locator('[data-web-shell-thinking-toggle]'); + await expect(thinking).toBeChecked(); + await expect(thinking).toBeDisabled(); + await expect(modelButton).toContainText('Extra High'); + const medium = page.locator('[data-web-shell-effort="medium"]'); + await expect(medium).toBeEnabled(); + await medium.click(); + await expect(medium).toHaveAttribute('aria-pressed', 'true'); + await expect(modelButton).toContainText('Medium'); + expect(daemon.configOptionRequests()).toHaveLength(0); + + await page.keyboard.press('Escape'); + await fillComposer(page, 'Create the mandatory-thinking session'); + await page.locator('[data-web-shell-composer-submit]').click(); + await expect.poll(() => daemon.configOptionRequests().length).toBe(1); + expect( + requestBodyRecord(firstRequest(daemon.configOptionRequests())), + ).toEqual({ configId: 'reasoning_effort', value: 'medium' }); + await expect.poll(() => daemon.promptRequests().length).toBe(1); +}); + test('does not apply a model-bound welcome effort after switching models @smoke', async ({ page, }, testInfo) => { diff --git a/packages/webui/src/daemon/session/mappers.test.ts b/packages/webui/src/daemon/session/mappers.test.ts index b3dc9ff2ee2..3ba3faa59d1 100644 --- a/packages/webui/src/daemon/session/mappers.test.ts +++ b/packages/webui/src/daemon/session/mappers.test.ts @@ -96,6 +96,29 @@ describe('mapReasoningControls', () => { efforts: [], }); }); + + it('maps mandatory reasoning without inventing Thinking off', () => { + expect( + mapReasoningControls([ + { + id: 'reasoning_effort', + currentValue: 'xhigh', + options: [{ value: 'low' }, { value: 'medium' }, { value: 'xhigh' }], + _meta: { + 'qwenCode/reasoning': { + defaultEffort: 'xhigh', + thinkingMandatory: true, + }, + }, + }, + ]), + ).toEqual({ + enabled: true, + effort: 'xhigh', + efforts: ['low', 'medium', 'xhigh'], + canDisable: false, + }); + }); }); describe('mapProviderStatus reasoning preview', () => { diff --git a/packages/webui/src/daemon/session/mappers.ts b/packages/webui/src/daemon/session/mappers.ts index 84a71802bc0..742f57e37d7 100644 --- a/packages/webui/src/daemon/session/mappers.ts +++ b/packages/webui/src/daemon/session/mappers.ts @@ -149,11 +149,13 @@ export function mapReasoningControls( const value = getString(getRecord(item), 'value'); return value ? [value] : []; }); - if (!values.includes('none')) return undefined; - const currentValue = getString(option, 'currentValue'); - if (!currentValue || !values.includes(currentValue)) return undefined; const meta = getRecord(option['_meta']); const reasoningMeta = getRecord(meta?.['qwenCode/reasoning']); + const thinkingMandatory = reasoningMeta?.['thinkingMandatory'] === true; + if (!thinkingMandatory && !values.includes('none')) return undefined; + 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) { @@ -161,6 +163,7 @@ export function mapReasoningControls( enabled: currentValue !== 'none', effort: selectableValues[0]!, efforts: [], + ...(thinkingMandatory ? { canDisable: false } : {}), }; } const efforts = selectableValues; @@ -170,7 +173,12 @@ export function mapReasoningControls( (value): value is string => typeof value === 'string' && efforts.includes(value), ) ?? efforts[0]!; - return { enabled: currentValue !== 'none', effort, efforts }; + return { + enabled: currentValue !== 'none', + effort, + efforts, + ...(thinkingMandatory ? { canDisable: false } : {}), + }; } export function mapSessionContextReasoning( diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index 4ce9db8d675..5dbe4f73f90 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -118,6 +118,8 @@ export interface DaemonReasoningControls { enabled: boolean; effort: string; efforts: string[]; + /** Defaults to true. False means effort is mutable but thinking is required. */ + canDisable?: boolean; } export interface DaemonTokenUsage {