Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/design/model-reasoning-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Model reasoning capabilities

## Goal

Expose accurate reasoning controls for common Alibaba Cloud Coding Plan and
Token Plan models without inventing effort levels that their Chat Completions
APIs do not support.

## Research

Alibaba Cloud documents `qwen3.8-max` with native `low`, `medium`, and `xhigh`
reasoning effort levels. It documents `qwen3.6-plus`, `qwen3.6-flash`,
`qwen3.7-plus`, `qwen3.7-max`, and `qwen3.5-plus` as hybrid-thinking models that
can enable or disable thinking, but use an integer thinking budget instead of
discrete Chat Completions effort levels.

The registered capabilities therefore are:

| Exact model id | Thinking | Effort control |
| --------------- | -------- | ------------------------ |
| `qwen3.8-max` | Optional | `low`, `medium`, `xhigh` |
| `qwen3.7-max` | Optional | None |
| `qwen3.7-plus` | Optional | None |
| `qwen3.6-plus` | Optional | None |
| `qwen3.6-flash` | Optional | None |
| `qwen3.5-plus` | Optional | None |

Sources:

- [Qwen Code and Coding Plan model ids](https://help.aliyun.com/zh/model-studio/qwen-code)
- [Thinking modes and defaults](https://help.aliyun.com/zh/model-studio/deep-thinking)
- [Chat Completions thinking parameters](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-chat-completions)

## Design

The model manifest distinguishes tiered reasoning from toggle-only reasoning
and continues to match exact model ids. Toggle-only models produce the same ACP
configuration option as tiered models, with two values: `none` and `default`.
Selecting `none` disables thinking for the live session. Selecting `default`
clears the session override so the existing model or provider default applies.

The daemon marks toggle-only options in ACP metadata. WebShell maps them to an
empty effort list, renders only the Thinking switch, and shows `Thinking` or
`Thinking Off` on the model chip. Existing tiered controls retain their effort
rows and labels.

Opening the controls does not mutate generation settings. No provider,
authentication, persistence, or runtime-snapshot behavior changes.

## Deferred models

DeepSeek, GLM, Kimi, and Grok models are not registered here. Their reasoning
parameters or defaults vary between direct and Alibaba Cloud endpoints, while
the current manifest is keyed only by model id. Registering them before the
provider path carries the relevant capability context could display a control
whose selected value is not sent correctly.

Qwen aliases, dated variants, coder models, and models with a preset default
that differs from the model default are also deferred. They require separate
capability or resolved-configuration semantics rather than broadened matching.

## Compatibility

Older ACP clients can continue to treat the option as a normal select. Older
daemons do not advertise the toggle-only metadata, so current WebShell keeps
the controls hidden unless the capability is explicit.
73 changes: 73 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7172,6 +7172,79 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
}
});

it('projects toggle-only Qwen reasoning without effort tiers', async () => {
const sessionId = 'qwen37-toggle-reasoning-session';
const innerConfig = await setupSessionMocks(sessionId);
const generation: {
reasoning?: false | { effort?: string };
} = {};
innerConfig.getModel = vi.fn().mockReturnValue('qwen3.7-plus');
innerConfig.getContentGeneratorConfig = vi.fn(() => generation);
innerConfig.getReasoningEffort = vi.fn(() =>
generation.reasoning ? generation.reasoning.effort : undefined,
);

const { agent, agentPromise } = await bootAcpAgent();
try {
const session = (await agent.newSession({
cwd: '/tmp',
mcpServers: [],
})) as {
configOptions: Array<{
id: string;
currentValue: string;
options: Array<{ value: string }>;
_meta?: Record<string, unknown>;
}>;
};
const option = session.configOptions.find(
(item) => item.id === 'reasoning_effort',
);
expect(option).toMatchObject({
currentValue: 'default',
options: [{ value: 'none' }, { value: 'default' }],
_meta: {
'qwenCode/reasoning': { toggleOnly: true },
},
});

const disabled = (await agent.setSessionConfigOption({
sessionId,
configId: 'reasoning_effort',
value: 'none',
})) as SetSessionConfigOptionResponse;
expect(generation.reasoning).toBe(false);
expect(
disabled.configOptions.find((item) => item.id === 'reasoning_effort')
?.currentValue,
).toBe('none');

const enabled = (await agent.setSessionConfigOption({
sessionId,
configId: 'reasoning_effort',
value: 'default',
})) as SetSessionConfigOptionResponse;
expect(generation.reasoning).toBeUndefined();
expect(
enabled.configOptions.find((item) => item.id === 'reasoning_effort')
?.currentValue,
).toBe('default');

await expect(
agent.setSessionConfigOption({
sessionId,
configId: 'reasoning_effort',
value: 'low',
}),
).rejects.toThrow(
'Unknown reasoning effort: low. Choose one of: none, default',
);
} finally {
mockConnectionState.resolve();
await agentPromise;
}
});

it('hides qwen3.8-max reasoning controls when thinking is mandatory', async () => {
const sessionId = 'qwen38-mandatory-thinking-session';
const innerConfig = await setupSessionMocks(sessionId);
Expand Down
51 changes: 39 additions & 12 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5913,19 +5913,32 @@ class QwenAgent implements Agent {
session.getConfig(),
);
if (modelReasoning) {
const effortValues = modelReasoning.toggleOnly
? undefined
: modelReasoning.efforts;
const selected =
value === ACP_REASONING_EFFORT_NONE
? ACP_REASONING_EFFORT_NONE
: modelReasoning.efforts.find((effort) => effort === value);
: modelReasoning.toggleOnly
? value === ACP_REASONING_EFFORT_DEFAULT
? ACP_REASONING_EFFORT_DEFAULT
: undefined
: effortValues?.find((effort) => effort === value);
if (!selected) {
const choices = [
ACP_REASONING_EFFORT_NONE,
...(effortValues ?? [ACP_REASONING_EFFORT_DEFAULT]),
];
throw RequestError.invalidParams(
undefined,
`Unknown reasoning effort: ${value}. Choose one of: ${ACP_REASONING_EFFORT_NONE}, ${modelReasoning.efforts.join(', ')}`,
`Unknown reasoning effort: ${value}. Choose one of: ${choices.join(', ')}`,
);
}
const generation = session.getConfig().getContentGeneratorConfig();
if (selected === ACP_REASONING_EFFORT_NONE) {
generation.reasoning = false;
} else if (selected === ACP_REASONING_EFFORT_DEFAULT) {
generation.reasoning = undefined;
} else {
const current = generation.reasoning;
generation.reasoning = {
Expand Down Expand Up @@ -13118,24 +13131,36 @@ class QwenAgent implements Agent {
currentValue:
config.getContentGeneratorConfig().reasoning === false
? ACP_REASONING_EFFORT_NONE
: (modelReasoning.efforts.find(
(effort) => effort === currentModelEffort,
) ?? modelReasoning.defaultEffort),
: modelReasoning.toggleOnly
? ACP_REASONING_EFFORT_DEFAULT
: (modelReasoning.efforts.find(
(effort) => effort === currentModelEffort,
) ?? modelReasoning.defaultEffort),
options: [
{
value: ACP_REASONING_EFFORT_NONE,
name: 'Thinking off',
description: 'Disable thinking for this session',
},
...modelReasoning.efforts.map((effort) => ({
value: effort,
name: ACP_REASONING_EFFORT_NAMES[effort],
description: 'Apply this effort to the next request',
})),
...(modelReasoning.toggleOnly
? [
{
value: ACP_REASONING_EFFORT_DEFAULT,
name: 'Thinking on',
description: 'Use the model or provider thinking default',
},
]
: modelReasoning.efforts.map((effort) => ({
value: effort,
name: ACP_REASONING_EFFORT_NAMES[effort],
description: 'Apply this effort to the next request',
}))),
],
_meta: {
'qwenCode/reasoning': {
defaultEffort: modelReasoning.defaultEffort,
...(modelReasoning.toggleOnly
? { toggleOnly: true }
: { defaultEffort: modelReasoning.defaultEffort }),
},
},
}
Expand Down Expand Up @@ -13177,7 +13202,9 @@ class QwenAgent implements Agent {
const reasoning = getModelConfiguration(config.getModel())?.reasoning;
const currentEffort = config.getReasoningEffort?.();
return reasoning?.thinking &&
(!currentEffort || reasoning.efforts.includes(currentEffort))
(reasoning.toggleOnly ||
!currentEffort ||
reasoning.efforts.includes(currentEffort))
? reasoning
: undefined;
}
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/acp-integration/model-configuration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,33 @@ describe('model configuration manifest', () => {
});
});

it.each([
'qwen3.5-plus',
'qwen3.6-plus',
'qwen3.6-flash',
'qwen3.7-plus',
'qwen3.7-max',
])('registers toggle-only reasoning for %s', (modelId) => {
expect(getModelConfiguration(modelId)).toEqual({
reasoning: {
thinking: true,
toggleOnly: true,
},
});
});

it.each([
undefined,
'qwen3.8-max-preview',
'qwen3.8-max-latest',
'qwen3.8-max-2026-08-12',
'vendor/qwen3.8-max',
'qwen3.7-plus-latest',
'vendor/qwen3.7-plus',
'QWEN3.7-PLUS',
'qwen3-max-2026-01-23',
'qwen3-coder-plus',
'qwen3-coder-next',
])('does not broaden the manifest to %s', (modelId) => {
expect(getModelConfiguration(modelId)).toBeUndefined();
});
Expand Down
42 changes: 31 additions & 11 deletions packages/cli/src/acp-integration/model-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,49 @@

import type { ReasoningEffort } from '@qwen-code/qwen-code-core';

export interface ModelReasoningConfiguration {
readonly thinking: true;
readonly efforts: readonly ReasoningEffort[];
readonly defaultEffort: ReasoningEffort;
}
export type ModelReasoningConfiguration =
| {
readonly thinking: true;
readonly toggleOnly: true;
}
| {
readonly thinking: true;
readonly toggleOnly?: false;
readonly efforts: readonly ReasoningEffort[];
readonly defaultEffort: ReasoningEffort;
};

const MODEL_CONFIGURATIONS = {
const MODEL_CONFIGURATIONS: Readonly<
Record<string, { readonly reasoning?: ModelReasoningConfiguration }>
> = {
'qwen3.5-plus': {
reasoning: { thinking: true, toggleOnly: true },
},
'qwen3.6-plus': {
reasoning: { thinking: true, toggleOnly: true },
},
'qwen3.6-flash': {
reasoning: { thinking: true, toggleOnly: true },
},
'qwen3.7-plus': {
reasoning: { thinking: true, toggleOnly: true },
},
'qwen3.7-max': {
reasoning: { thinking: true, toggleOnly: true },
},
'qwen3.8-max': {
reasoning: {
thinking: true,
efforts: ['low', 'medium', 'xhigh'],
defaultEffort: 'xhigh',
},
},
} as const satisfies Record<
string,
{ reasoning?: ModelReasoningConfiguration }
>;
};

export function getModelConfiguration(modelId: string | undefined):
| {
readonly reasoning?: ModelReasoningConfiguration;
}
| undefined {
return modelId === 'qwen3.8-max' ? MODEL_CONFIGURATIONS[modelId] : undefined;
return modelId ? MODEL_CONFIGURATIONS[modelId] : undefined;
}
Loading
Loading