diff --git a/docs/users/integration-jetbrains.md b/docs/users/integration-jetbrains.md index f1efc7f55bd..70155bca315 100644 --- a/docs/users/integration-jetbrains.md +++ b/docs/users/integration-jetbrains.md @@ -8,6 +8,7 @@ - **Agent Client Protocol**: Full support for ACP enabling advanced IDE interactions - **Symbol management**: #-mention files to add them to the conversation context - **Conversation history**: Access to past conversations within the IDE +- **Reasoning effort**: Choose Default, Low, Medium, High, Extra high, or Max from the agent's session options; each provider maps or clamps the requested tier for the active model - **Context usage**: See the current context-window occupancy while Qwen Code works ### Requirements diff --git a/integration-tests/cli/acp-integration.test.ts b/integration-tests/cli/acp-integration.test.ts index 731b2946296..c93e4169994 100644 --- a/integration-tests/cli/acp-integration.test.ts +++ b/integration-tests/cli/acp-integration.test.ts @@ -486,7 +486,7 @@ function setupAcpTest( } }); - it('supports session/set_config_option for mode and model', async () => { + it('supports session/set_config_option for mode, model, and reasoning effort', async () => { const rig = new TestRig(); // Inject a deterministic openai provider model so `availableModels` always // contains a settable openai entry. The previous version relied on the @@ -533,9 +533,26 @@ function setupAcpTest( models: { availableModels: Array<{ modelId: string }>; }; + configOptions: Array<{ + id: string; + category?: string; + currentValue: string; + options: Array<{ value: string; name: string }>; + }>; }; expect(newSession.sessionId).toBeTruthy(); + const initialReasoningOption = newSession.configOptions.find( + (opt) => opt.id === 'reasoning_effort', + ); + expect(initialReasoningOption).toMatchObject({ + category: 'thought_level', + currentValue: 'default', + }); + expect( + initialReasoningOption?.options.map((option) => option.value), + ).toEqual(['default', 'low', 'medium', 'high', 'xhigh', 'max']); + // Test: Set mode using set_config_option const setModeResult = (await sendRequest('session/set_config_option', { sessionId: newSession.sessionId, @@ -598,6 +615,57 @@ function setupAcpTest( ); expect(updatedModelOption).toBeDefined(); expect(updatedModelOption!.currentValue).toBe(openaiModel!.modelId); + expect( + setModelResult.configOptions.find( + (opt) => opt.id === 'reasoning_effort', + )?.currentValue, + ).toBe('default'); + + const setReasoningResult = (await sendRequest( + 'session/set_config_option', + { + sessionId: newSession.sessionId, + configId: 'reasoning_effort', + value: 'xhigh', + }, + )) as { + configOptions: Array<{ id: string; currentValue: string }>; + }; + expect( + setReasoningResult.configOptions.find( + (opt) => opt.id === 'reasoning_effort', + )?.currentValue, + ).toBe('xhigh'); + + const resetReasoningResult = (await sendRequest( + 'session/set_config_option', + { + sessionId: newSession.sessionId, + configId: 'reasoning_effort', + value: 'default', + }, + )) as { + configOptions: Array<{ id: string; currentValue: string }>; + }; + expect( + resetReasoningResult.configOptions.find( + (opt) => opt.id === 'reasoning_effort', + )?.currentValue, + ).toBe('default'); + + await expect( + sendRequest('session/set_config_option', { + sessionId: newSession.sessionId, + configId: 'reasoning_effort', + value: 'ultra', + }), + ).rejects.toMatchObject({ + response: { + code: -32602, + message: + 'Invalid params: Unknown reasoning effort: ultra. Choose one of: default, low, medium, high, xhigh, max', + }, + }); } catch (e) { if (stderr.length) { console.error('Agent stderr:', stderr.join('')); diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 28f0c5a40f7..83b64e4061d 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -224,6 +224,17 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({ })), APPROVAL_MODE_INFO: {}, APPROVAL_MODES: [], + applyReasoningEffort: ( + config: { + setReasoningEffort(effort: string | undefined): void; + getReasoningEffort(): string | undefined; + }, + effort: string | undefined, + ) => { + config.setReasoningEffort(effort); + return config.getReasoningEffort() === effort; + }, + REASONING_EFFORT_TIERS: ['low', 'medium', 'high', 'xhigh', 'max'], ApprovalMode: { YOLO: 'yolo' }, isGatedMcpScope: (scope: unknown) => scope === 'project' || scope === 'workspace', @@ -1854,6 +1865,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => { type AgentLike = { initialize: (args: Record) => Promise; newSession: (args: Record) => Promise; + setSessionConfigOption: (args: Record) => Promise; beginManagedShutdown: () => { configs: Config[]; writerShutdown: Promise; @@ -3198,6 +3210,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => { getAvailableModels: vi.fn().mockReturnValue([]), getModes: vi.fn().mockReturnValue([]), getApprovalMode: vi.fn().mockReturnValue('default'), + getReasoningEffort: vi.fn().mockReturnValue(undefined), + setReasoningEffort: vi.fn(), getSessionId: vi.fn().mockReturnValue('test-session-id'), getAuthType: vi.fn().mockReturnValue('api-key'), getAllConfiguredModels: vi.fn().mockReturnValue([]), @@ -6212,6 +6226,107 @@ describe('QwenAgent MCP SSE/HTTP support', () => { await agentPromise; }); + it('exposes and applies the ACP reasoning effort selector', async () => { + const sessionId = 'reasoning-effort-session'; + const innerConfig = await setupSessionMocks(sessionId); + let currentEffort: string | undefined; + 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 { + configOptions: Array<{ + id: string; + category?: string; + currentValue: string; + options: Array<{ + value: string; + name: string; + description: string; + }>; + }>; + }; + + expect( + session.configOptions.find( + (option) => option.id === 'reasoning_effort', + ), + ).toMatchObject({ + category: 'thought_level', + currentValue: 'default', + options: [ + { 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', + description: expect.any(String), + }, + { value: 'max', name: 'Max', description: expect.any(String) }, + ], + }); + + const selected = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'xhigh', + })) as { configOptions: typeof session.configOptions }; + expect(innerConfig.setReasoningEffort).toHaveBeenCalledWith('xhigh'); + expect( + selected.configOptions.find( + (option) => option.id === 'reasoning_effort', + )?.currentValue, + ).toBe('xhigh'); + + const reset = (await agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'default', + })) as { configOptions: typeof session.configOptions }; + expect(innerConfig.setReasoningEffort).toHaveBeenLastCalledWith( + undefined, + ); + expect( + reset.configOptions.find((option) => option.id === 'reasoning_effort') + ?.currentValue, + ).toBe('default'); + + await expect( + agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'ultra', + }), + ).rejects.toThrow( + 'Unknown reasoning effort: ultra. Choose one of: default, low, medium, high, xhigh, max', + ); + expect(innerConfig.setReasoningEffort).toHaveBeenCalledTimes(2); + + innerConfig.setReasoningEffort.mockImplementation(() => {}); + await expect( + agent.setSessionConfigOption({ + sessionId, + configId: 'reasoning_effort', + value: 'xhigh', + }), + ).rejects.toThrow( + 'Reasoning effort cannot be applied while thinking is disabled', + ); + expect(innerConfig.setReasoningEffort).toHaveBeenCalledTimes(3); + } finally { + mockConnectionState.resolve(); + await agentPromise; + } + }); + it.each([ { description: 'hide discontinued qwen-oauth for other auth types', diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index e5be8851751..830c789b519 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -92,6 +92,8 @@ import { normalizeSnapshotPayload, startEventLoopLagMonitor, refreshMemoryInstruction, + applyReasoningEffort, + REASONING_EFFORT_TIERS, extractDaemonTraceContext, withDaemonSpan, type AgentParams, @@ -109,6 +111,7 @@ import { type ProviderConfig, type ProviderModelConfig, type ProviderSetupInputs, + type ReasoningEffort, type ResumedSessionData, type SendSdkMcpMessage, type SessionArtifactEventRecordPayload, @@ -363,6 +366,14 @@ const POSIX_TMP_LOCAL_READ_ROOT = '/tmp'; const BTW_CHILD_TIMEOUT_MS = 55_000; const MCP_OAUTH_START_TIMEOUT_MS = 30_000; const SESSION_DRAIN_TIMEOUT_MS = 30_000; +const ACP_REASONING_EFFORT_DEFAULT = 'default'; +const ACP_REASONING_EFFORT_NAMES: Record = { + low: 'Low', + medium: 'Medium', + high: 'High', + xhigh: 'Extra high', + max: 'Max', +}; // Must be less than WORKSPACE_MEMORY_REMEMBER_TIMEOUT_MS (300s) in bridge.ts. const WORKSPACE_MEMORY_REMEMBER_CHILD_TIMEOUT_MS = 295_000; @@ -5173,6 +5184,25 @@ class QwenAgent implements Agent { ); break; } + case 'reasoning_effort': { + const effort = + value === ACP_REASONING_EFFORT_DEFAULT + ? undefined + : REASONING_EFFORT_TIERS.find((tier) => tier === value); + if (value !== ACP_REASONING_EFFORT_DEFAULT && effort === undefined) { + throw RequestError.invalidParams( + undefined, + `Unknown reasoning effort: ${value}. Choose one of: ${ACP_REASONING_EFFORT_DEFAULT}, ${REASONING_EFFORT_TIERS.join(', ')}`, + ); + } + if (!applyReasoningEffort(session.getConfig(), effort)) { + throw RequestError.invalidParams( + undefined, + 'Reasoning effort cannot be applied while thinking is disabled', + ); + } + break; + } default: throw RequestError.invalidParams( undefined, @@ -11973,7 +12003,30 @@ class QwenAgent implements Agent { options: configModelOptions, }; - return [modeConfigOption, modelConfigOption]; + const reasoningEffortConfigOption: SessionConfigOption = { + id: 'reasoning_effort', + name: 'Reasoning effort', + description: 'How hard reasoning-capable models should think', + category: 'thought_level', + type: 'select' as const, + currentValue: + config.getReasoningEffort?.() ?? ACP_REASONING_EFFORT_DEFAULT, + options: [ + { + value: ACP_REASONING_EFFORT_DEFAULT, + name: 'Default', + description: 'Use the model or provider default', + }, + ...REASONING_EFFORT_TIERS.map((effort) => ({ + value: effort, + name: ACP_REASONING_EFFORT_NAMES[effort], + description: + 'Providers map or clamp the requested tier for the active model', + })), + ], + }; + + return [modeConfigOption, modelConfigOption, reasoningEffortConfigOption]; } 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 433dfa3bb11..746c38644b6 100644 --- a/packages/cli/src/acp-integration/acpAgent.worktree.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.worktree.test.ts @@ -113,6 +113,17 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ })), APPROVAL_MODE_INFO: {}, APPROVAL_MODES: [], + applyReasoningEffort: ( + config: { + setReasoningEffort(effort: string | undefined): void; + getReasoningEffort(): string | undefined; + }, + effort: string | undefined, + ) => { + config.setReasoningEffort(effort); + return config.getReasoningEffort() === effort; + }, + REASONING_EFFORT_TIERS: ['low', 'medium', 'high', 'xhigh', 'max'], DEFAULT_STOP_HOOK_BLOCK_CAP: 8, DEFAULT_MAX_SUBAGENT_DEPTH: 5, DEFAULT_MAX_TOOL_CALLS_PER_TURN: 100, diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts index 7b3994d78a1..2b6c72a2482 100644 --- a/packages/cli/src/nonInteractive/control/controllers/systemController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -28,6 +28,7 @@ import { createDebugLogger, MCPServerConfig, AuthProviderType, + applyReasoningEffort, normalizeReasoningEffort, loadUsageDashboard, type MCPOAuthConfig, @@ -176,16 +177,14 @@ export class SystemController extends BaseController { const normalized = normalizeReasoningEffort(payload.effort); if (normalized) { try { - this.context.config.setReasoningEffort(normalized); - - if (this.context.config.getReasoningEffort() !== normalized) { - debugLogger.warn( - `[SystemController] Effort '${normalized}' was not applied (thinking may be disabled)`, - ); - } else { + if (applyReasoningEffort(this.context.config, normalized)) { debugLogger.info( `[SystemController] Set reasoning effort to: ${normalized}`, ); + } else { + debugLogger.warn( + `[SystemController] Effort '${normalized}' was not applied (thinking may be disabled)`, + ); } } catch (error) { debugLogger.error( @@ -533,9 +532,7 @@ export class SystemController extends BaseController { } try { - this.context.config.setReasoningEffort(normalized); - - const applied = this.context.config.getReasoningEffort() === normalized; + const applied = applyReasoningEffort(this.context.config, normalized); debugLogger.info( `[SystemController] Reasoning effort set to: ${normalized} (applied: ${applied})`, diff --git a/packages/cli/src/serve/acp-http/dispatch.ts b/packages/cli/src/serve/acp-http/dispatch.ts index 5c291d64106..a0a2e34ad55 100644 --- a/packages/cli/src/serve/acp-http/dispatch.ts +++ b/packages/cli/src/serve/acp-http/dispatch.ts @@ -385,6 +385,14 @@ const DEFAULT_FILE_GLOB_MAX_RESULTS = 5000; const MAX_FILE_GLOB_MAX_RESULTS = 50_000; const MAX_FILE_LINE_LIMIT = 2000; +/** + * Config-option ids this HTTP transport's `session/set_config_option` can + * route. Shared by `configOptionsFor`'s advertisement filter and the + * setter's rejection message so the advertised set and the set reported as + * supported cannot drift apart. + */ +const HTTP_ACP_CONFIG_OPTION_IDS: readonly string[] = ['model', 'mode']; + class AcpParamError extends Error {} function parseOptionalPositiveInteger( @@ -981,9 +989,14 @@ export class AcpDispatcher { } /** - * The session's ACP-shaped config options (model/mode/…), read from the - * child's own session state. Returned in `session/new` and as the result - * of `session/set_config_option`. Best-effort — `undefined` on error. + * The session's ACP-shaped config options supported by this HTTP transport + * (currently model and mode), read from the child's own session state. + * Every response built from this helper (`session/new`, + * `session/load`/`session/resume`, `session/fork`, and the + * `session/set_config_option` result) is gated to the ids the transport + * can route. Raw-state surfaces (context status, REST load/resume state) + * intentionally carry the child's full unfiltered set — they report session + * state. Best-effort — `undefined` on error. */ private async configOptionsFor( sessionId: string, @@ -993,7 +1006,13 @@ export class AcpDispatcher { state?: { configOptions?: unknown }; }; const co = ctx?.state?.configOptions; - return Array.isArray(co) ? co : undefined; + return Array.isArray(co) + ? co.filter( + (option) => + isObject(option) && + HTTP_ACP_CONFIG_OPTION_IDS.includes(option['id'] as string), + ) + : undefined; } catch (err) { writeStderrLine( `qwen serve: /acp configOptionsFor(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`, @@ -2230,6 +2249,8 @@ export class AcpDispatcher { ctx, ); } else { + // Ids advertised by raw-state surfaces but unroutable here + // (e.g. reasoning_effort) must fail loud, not fall into mode. if (id !== undefined) { this.replySession( conn, @@ -2239,7 +2260,7 @@ export class AcpDispatcher { error( id, RPC.INVALID_PARAMS, - `Unknown configId: ${configId}`, + `ConfigId not supported by this transport: ${configId} (supported: ${HTTP_ACP_CONFIG_OPTION_IDS.join(', ')})`, ), ); } diff --git a/packages/cli/src/serve/acp-http/transport.test.ts b/packages/cli/src/serve/acp-http/transport.test.ts index 535ca755aa9..7473a1a1dbc 100644 --- a/packages/cli/src/serve/acp-http/transport.test.ts +++ b/packages/cli/src/serve/acp-http/transport.test.ts @@ -329,6 +329,22 @@ class FakeBridge { currentValue: 'qwen-max', options: [], }, + { + id: 'reasoning_effort', + name: 'Reasoning effort', + category: 'thought_level', + type: 'select', + currentValue: 'default', + options: [], + }, + { + id: 'mode', + name: 'Mode', + category: 'mode', + type: 'select', + currentValue: 'default', + options: [], + }, ], }, }; @@ -1275,10 +1291,17 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(ack.status).toBe(202); const [frame] = (await got) as Array<{ id: number; - result: { sessionId: string }; + result: { + sessionId: string; + configOptions: Array<{ id: string }>; + }; }>; expect(frame.id).toBe(2); expect(frame.result.sessionId).toBe('sess-1'); + expect(frame.result.configOptions.map((option) => option.id)).toEqual([ + 'model', + 'mode', + ]); }); it('session/new rejects the daemon-owned Live Voice source namespace', async () => { @@ -4705,6 +4728,56 @@ describe('ACP Streamable HTTP transport (over the wire)', () => { expect(bridge.lastApprovalMode).toBeUndefined(); }); + it('session/set_config_option rejects ids outside the routable set', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 44, + method: 'session/set_config_option', + params: { + sessionId: 'sess-1', + configId: 'reasoning_effort', + value: 'high', + }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number; message: string }; + }>; + expect(frame.error.code).toBe(-32602); + expect(frame.error.message).toContain( + 'ConfigId not supported by this transport: reasoning_effort', + ); + expect(frame.error.message).toContain('(supported: model, mode)'); + expect(bridge.lastSetModel).toBeUndefined(); + expect(bridge.lastApprovalMode).toBeUndefined(); + }); + + it('session/set_config_option without an id writes no response for an unroutable configId', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 50)); + const ack = await post(connId, { + jsonrpc: '2.0', + method: 'session/set_config_option', + params: { + sessionId: 'sess-1', + configId: 'reasoning_effort', + value: 'high', + }, + }); + expect(ack.status).toBe(202); + const frames = await takeFrames(sessStream, 1, 300); + expect(frames).toHaveLength(0); + expect(bridge.lastSetModel).toBeUndefined(); + expect(bridge.lastApprovalMode).toBeUndefined(); + }); + it('session/new always uses thread scope (ACP standard compliance)', async () => { // ACP standard: session/new MUST create a new isolated session. // sessionScope param is ignored; bridge always gets 'thread'. diff --git a/packages/cli/src/ui/commands/effort-command.ts b/packages/cli/src/ui/commands/effort-command.ts index edf03c34c6a..34e3be1fe4e 100644 --- a/packages/cli/src/ui/commands/effort-command.ts +++ b/packages/cli/src/ui/commands/effort-command.ts @@ -14,6 +14,7 @@ import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { + applyReasoningEffort, normalizeReasoningEffort, REASONING_EFFORT_TIERS, } from '@qwen-code/qwen-code-core'; @@ -97,7 +98,7 @@ export const effortCommand: SlashCommand = { // Apply at runtime (takes effect next turn) and persist for future sessions. // Provider adapters clamp the tier to what the active model supports. - config.setReasoningEffort(tier); + const applied = applyReasoningEffort(config, tier); settings.setValue( getPersistScopeForModelSelection(settings), 'model.reasoningEffort', @@ -108,7 +109,7 @@ export const effortCommand: SlashCommand = { // (`reasoning: false`), so effort cannot silently re-enable it. The tier is // still persisted for future sessions, but report that it won't take effect // yet instead of a misleading success message. - if (config.getReasoningEffort() !== tier) { + if (!applied) { return { type: 'message', messageType: 'info', diff --git a/packages/cli/src/ui/hooks/use-effort-command.test.ts b/packages/cli/src/ui/hooks/use-effort-command.test.ts index 02a025c98f6..5507d626262 100644 --- a/packages/cli/src/ui/hooks/use-effort-command.test.ts +++ b/packages/cli/src/ui/hooks/use-effort-command.test.ts @@ -12,14 +12,16 @@ import { useEffortCommand } from './use-effort-command.js'; describe('useEffortCommand', () => { let setReasoningEffort: ReturnType; + let getReasoningEffort: ReturnType; let setValue: ReturnType; let config: Config; let settings: LoadedSettings; beforeEach(() => { setReasoningEffort = vi.fn(); + getReasoningEffort = vi.fn(); setValue = vi.fn(); - config = { setReasoningEffort } as unknown as Config; + config = { setReasoningEffort, getReasoningEffort } as unknown as Config; settings = { setValue, isTrusted: true, diff --git a/packages/cli/src/ui/hooks/use-effort-command.ts b/packages/cli/src/ui/hooks/use-effort-command.ts index 8d4b653f776..07b9ff7a11f 100644 --- a/packages/cli/src/ui/hooks/use-effort-command.ts +++ b/packages/cli/src/ui/hooks/use-effort-command.ts @@ -6,6 +6,7 @@ import { useState, useCallback } from 'react'; import type { Config, ReasoningEffort } from '@qwen-code/qwen-code-core'; +import { applyReasoningEffort } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { MessageType, type HistoryItemWithoutId } from '../types.js'; @@ -37,20 +38,19 @@ export const useEffortCommand = ( } // Apply at runtime (next turn) and persist for future sessions; provider // adapters clamp the tier to what the active model supports. - config.setReasoningEffort(effort); + const applied = applyReasoningEffort(config, effort); loadedSettings.setValue( getPersistScopeForModelSelection(loadedSettings), 'model.reasoningEffort', effort, ); - // Mirror the slash-command path's read-back so the dialog reports the - // outcome in-chat instead of silently closing (the status line is the - // only other signal). `setReasoningEffort` is a no-op when thinking is + // Report the outcome in-chat instead of silently closing (the status + // line is the only other signal). The setter no-ops when thinking is // explicitly disabled (`reasoning: false`): the tier is still persisted // for future sessions, but say it won't take effect until thinking is - // re-enabled; otherwise confirm the requested tier. + // re-enabled. if (addItem) { - if (config.getReasoningEffort() !== effort) { + if (!applied) { addItem( { type: MessageType.INFO, diff --git a/packages/core/src/core/reasoning-effort.test.ts b/packages/core/src/core/reasoning-effort.test.ts index ab5e83c3034..3f820146785 100644 --- a/packages/core/src/core/reasoning-effort.test.ts +++ b/packages/core/src/core/reasoning-effort.test.ts @@ -5,8 +5,10 @@ */ import { describe, it, expect } from 'vitest'; +import type { Config } from '../config/config.js'; import { REASONING_EFFORT_TIERS, + applyReasoningEffort, clampReasoningEffort, normalizeReasoningEffort, type ReasoningEffort, @@ -86,3 +88,41 @@ describe('clampReasoningEffort', () => { expect(clampReasoningEffort('xhigh', [])).toBe('xhigh'); }); }); + +describe('applyReasoningEffort', () => { + function makeConfig(thinkingDisabled = false) { + let stored: ReasoningEffort | undefined; + return { + setReasoningEffort(effort: ReasoningEffort | undefined) { + // Mirrors Config: a no-op when thinking is explicitly disabled. + if (thinkingDisabled) return; + stored = effort; + }, + getReasoningEffort() { + return stored; + }, + } as unknown as Config; + } + + it('applies the tier and reports true when the config accepts it', () => { + const config = makeConfig(); + expect(applyReasoningEffort(config, 'high')).toBe(true); + expect(config.getReasoningEffort()).toBe('high'); + }); + + it('reports false when setReasoningEffort no-ops (thinking disabled)', () => { + const config = makeConfig(true); + expect(applyReasoningEffort(config, 'high')).toBe(false); + expect(config.getReasoningEffort()).toBeUndefined(); + }); + + it('clearing the override always reports true', () => { + const active = makeConfig(); + applyReasoningEffort(active, 'max'); + expect(applyReasoningEffort(active, undefined)).toBe(true); + expect(active.getReasoningEffort()).toBeUndefined(); + + const disabled = makeConfig(true); + expect(applyReasoningEffort(disabled, undefined)).toBe(true); + }); +}); diff --git a/packages/core/src/core/reasoning-effort.ts b/packages/core/src/core/reasoning-effort.ts index f55dbdf74c2..aac10131782 100644 --- a/packages/core/src/core/reasoning-effort.ts +++ b/packages/core/src/core/reasoning-effort.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Config } from '../config/config.js'; + /** * Unified reasoning-effort ladder exposed to users (e.g. via `/effort`). * @@ -106,3 +108,19 @@ export function clampReasoningEffort( // Nothing at or above the request: fall back to the strongest available. return ranked[ranked.length - 1]!; } + +/** + * Set `effort` and read it back to confirm the config actually accepted it. + * `Config.setReasoningEffort` is a documented no-op when thinking is + * explicitly disabled (`reasoning: false`); returns false when the requested + * tier did not land so each surface can report the discard its own way + * instead of reporting success. Clearing the override (`undefined`) always + * reports true. + */ +export function applyReasoningEffort( + config: Config, + effort: ReasoningEffort | undefined, +): boolean { + config.setReasoningEffort(effort); + return config.getReasoningEffort() === effort; +}