mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(cli): expose reasoning effort through ACP (#8526)
* feat(cli): expose reasoning effort to ACP clients * fix(cli): address ACP reasoning effort review * fix(cli): address ACP reasoning effort review round 2 Co-authored-by: Qwen-Coder <[email protected]> * fix(cli): migrate /effort dialog to applyReasoningEffort helper Co-authored-by: Qwen-Coder <[email protected]> * fix(acp): harden set_config_option routing and rejection messages (#8526) --------- Co-authored-by: Shaojin Wen <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
13 files changed
+427
-27
No files matched your search
@@ -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
|
||||
|
||||
@@ -487,7 +487,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
|
||||
@@ -534,9 +534,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,
|
||||
@@ -599,6 +616,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(''));
|
||||
|
||||
@@ -231,6 +231,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',
|
||||
@@ -1871,6 +1882,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
|
||||
type AgentLike = {
|
||||
initialize: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
newSession: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
setSessionConfigOption: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
beginManagedShutdown: () => {
|
||||
configs: Config[];
|
||||
writerShutdown: Promise<void>;
|
||||
@@ -3339,6 +3351,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([]),
|
||||
@@ -6353,6 +6367,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',
|
||||
|
||||
@@ -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,
|
||||
@@ -376,6 +379,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<ReasoningEffort, string> = {
|
||||
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;
|
||||
@@ -5426,6 +5437,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,
|
||||
@@ -12264,7 +12294,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) {
|
||||
|
||||
@@ -125,6 +125,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,
|
||||
|
||||
@@ -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})`,
|
||||
|
||||
@@ -406,6 +406,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 {}
|
||||
|
||||
class InvalidRequestedSessionIdError extends Error {}
|
||||
@@ -1145,9 +1153,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,
|
||||
@@ -1158,7 +1171,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))}`,
|
||||
@@ -2522,6 +2541,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,
|
||||
@@ -2531,7 +2552,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(', ')})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -339,6 +339,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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1318,10 +1334,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 () => {
|
||||
@@ -4813,6 +4836,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'.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -12,14 +12,16 @@ import { useEffortCommand } from './use-effort-command.js';
|
||||
|
||||
describe('useEffortCommand', () => {
|
||||
let setReasoningEffort: ReturnType<typeof vi.fn>;
|
||||
let getReasoningEffort: ReturnType<typeof vi.fn>;
|
||||
let setValue: ReturnType<typeof vi.fn>;
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user