Skip to content
Closed
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
971a719
feat(web-shell): add qwen3.8-max reasoning controls
callmeYe Aug 7, 2026
3412461
feat(web-shell): generalize model reasoning controls
callmeYe Aug 7, 2026
b832da2
feat(web-shell): add reasoning model submenu
callmeYe Aug 7, 2026
23c34f9
fix(web-shell): refine reasoning model controls
callmeYe Aug 7, 2026
8470c6c
fix(web-shell): tune model submenu spacing
callmeYe Aug 7, 2026
ba86188
chore(web-shell): remove local reasoning mock
callmeYe Aug 7, 2026
6792c84
Merge remote-tracking branch 'origin/main' into codex/pr-8675-heartbeat
callmeYe Aug 7, 2026
14ab3ac
fix(web-shell): cover reasoning control compatibility paths
callmeYe Aug 7, 2026
44695cc
fix(web-shell): preserve reasoning state across session edges
callmeYe Aug 7, 2026
1a891fb
Merge remote-tracking branch 'origin/main' into codex/pr-8675-heartbeat
callmeYe Aug 7, 2026
def62ea
test(serve): include reasoning capability in integration baseline
callmeYe Aug 7, 2026
1c8f52d
Merge remote-tracking branch 'origin/main' into codex/pr-8675-heartbeat
callmeYe Aug 7, 2026
49c8740
fix: address review feedback for qwen3.8-max reasoning options (#8675)
qwen-code-dev-bot Aug 7, 2026
8c5cd8a
fix(core): scope thinking-off refresh state to model
callmeYe Aug 7, 2026
0b9a2ec
Merge remote-tracking branch 'origin/main' into codex/pr-8675-heartbeat
callmeYe Aug 7, 2026
8859771
fix: address round-2 review feedback for qwen3.8-max reasoning option…
qwen-code-dev-bot Aug 7, 2026
d7bb54a
Merge remote-tracking branch 'origin/codex/webshell-qwen38-max-reason…
callmeYe Aug 8, 2026
5de46a2
Merge remote-tracking branch 'origin/main' into codex/pr-8675-heartbeat
callmeYe Aug 8, 2026
9eb2be2
Merge remote-tracking branch 'origin/main' into codex/pr-8675-heartbeat
callmeYe Aug 8, 2026
c5069b1
Merge remote-tracking branch 'origin/main' into codex/pr-8675-heartbeat
callmeYe Aug 8, 2026
edb1992
fix(sdk): silence config_option_update; normalize reasoning registry …
qwen-code-dev-bot Aug 8, 2026
f9b97e5
docs(design): note default outbound reasoning payload for qwen3.8-max…
qwen-code-dev-bot Aug 8, 2026
272d2e7
Merge branch 'main' into codex/webshell-qwen38-max-reasoning-options
qwen-code-dev-bot Aug 8, 2026
db34907
Merge branch 'main' into codex/webshell-qwen38-max-reasoning-options
qwen-code-dev-bot Aug 9, 2026
628ff58
chore: merge origin/main into codex/webshell-qwen38-max-reasoning-opt…
qwen-code-dev-bot Aug 9, 2026
1320418
chore: merge origin/main into codex/webshell-qwen38-max-reasoning-opt…
qwen-code-dev-bot Aug 9, 2026
e685b99
fix(reasoning): isolate model reasoning state
callmeYe Aug 10, 2026
ba84526
Merge remote-tracking branch 'origin/main' into codex/pr-8675-round3
callmeYe Aug 10, 2026
f62a6e5
chore(sdk): update daemon bundle budget
callmeYe Aug 10, 2026
85a497d
Merge remote-tracking branch 'origin/main' into qwen-review/pr-8675
callmeYe Aug 10, 2026
14ae1e7
fix: close reasoning control review gaps
callmeYe Aug 10, 2026
f54c14c
test: remove superseded reasoning regressions
callmeYe Aug 10, 2026
0c28a88
fix: resolve reasoning state review blockers
callmeYe Aug 11, 2026
fd1f436
fix(core): preserve runtime snapshot reasoning
callmeYe Aug 11, 2026
3a3e964
fix: preserve reasoning scope across model changes
callmeYe Aug 11, 2026
eaeeb71
chore: merge main into reasoning controls
callmeYe Aug 11, 2026
d9cafef
test(cli): update session telemetry route count
callmeYe Aug 11, 2026
630c79a
chore: merge main into reasoning controls
callmeYe Aug 11, 2026
537080a
chore: merge main into reasoning controls
callmeYe Aug 11, 2026
0973de8
Merge remote-tracking branch 'origin/main' into codex/pr-8675-r8-fix
callmeYe Aug 11, 2026
004bc55
fix: address reasoning review blockers
callmeYe Aug 11, 2026
b3f6319
fix: address round 9 review blockers
callmeYe Aug 11, 2026
3bf0acd
Merge remote-tracking branch 'origin/main' into codex/pr-8675-r8-fix
callmeYe Aug 12, 2026
f4a4592
fix: address round 10 review blockers
callmeYe Aug 12, 2026
8cb1088
fix: address round 11 review blockers
callmeYe Aug 12, 2026
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
72 changes: 72 additions & 0 deletions docs/design/webshell-qwen38-max-reasoning-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Model reasoning controls registry

## Goal

Expose model-specific Thinking and reasoning-effort controls without teaching
clients about individual model ids. A built-in Core registry declares which
controls each exact bare model id supports and their defaults.

`qwen3.8-max` supports `low`, `medium`, and `xhigh`; its default is
`xhigh`. `qwen3.8-max-preview`, runtime snapshots, and aliases are
deliberately excluded.

## Design

The registry independently declares optional `thinking` and `effort`
capabilities. An effort registration contains its ordered supported tiers and
default. The first registration is `qwen3.8-max`: Thinking defaults on and
Effort supports `low`, `medium`, and `xhigh`, defaulting to `xhigh`.

These defaults are not UI-only. When a registered model has no stored
preference and no provider-provided reasoning state, resolution writes the
defaults into the generation config, so every surface (CLI, daemon, ACP,
channels) sends `reasoning: { effort: 'xhigh' }` to `qwen3.8-max` instead of
omitting the field and leaving the tier to the server's default.

The ACP session `configOptions` snapshot is the source of truth for live
sessions. The agent dynamically advertises the registered `thought_level`
options for the active model:

- `thinking`: `on` or `off`
- `effort`: `low`, `medium`, or `xhigh`

Changes travel through a daemon session config-option mutation and are echoed
as ACP `config_option_update` notifications. Web clients update their cached
connection state from both the attach snapshot and live notifications.
The ACP session also observes Core model changes, so model switches initiated
by slash commands or workspace reloads re-apply the active model's preferences
and publish the same config and model notifications as the session API.

The daemon's workspace model catalog carries the same optional capability
metadata so a composer can render controls before a session exists. Older
daemons omit both the capability metadata and `session_reasoning_control`, so
clients retain the model-only picker.

Persisted values live in `model.reasoningPreferences`, keyed by exact bare
model id. Each entry can store `thinkingEnabled` and `effort`. Missing or
invalid values use the registry defaults and never inherit the global
`model.reasoningEffort`; the global setting remains unchanged for unregistered
models. Turning thinking off does not erase the tier. TUI `/effort` keeps its
Comment thread
callmeYe marked this conversation as resolved.
Resolved
five-tier picker but normalizes a selection to the registered model's supported
subset before storing it.

Before a live session exists, WebShell merges the selected model's preference
into the map through the workspace settings API. Once attached, it uses the
session mutation so the current session and future sessions stay in sync. A
failed post-write settings reload is surfaced as an update error rather than
leaving the composer on stale state.

## UI behavior

The existing model chip remains the only entry point. For a model with
reasoning controls, the popover's first level renders each registered section
independently (Thinking, then Effort) and moves the searchable model list
behind a second-level "Model" submenu; models without reasoning controls keep
the flat searchable list. This supports Thinking-only, Effort-only, and
combined models. Effort rows remain visible but disabled while Thinking is off
when both controls exist.
Changing an option keeps the popover open. During an active response the
controls remain available; the new value applies to the next request.

Older daemons that do not advertise `session_reasoning_control` keep the
existing model-only popover.
34 changes: 20 additions & 14 deletions integration-tests/cli/acp-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,20 +791,26 @@ function setupAcpTest(
// Track which permission requests we've seen
const planModeRequests: PermissionRequest[] = [];

const { sendRequest, cleanup, stderr, sessionUpdates, permissionRequests, agent } =
setupAcpTest(rig, {
permissionHandler: (request) => {
// Track all permission requests for later verification
// Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode
if (request.toolCall?.kind === 'switch_mode') {
planModeRequests.push(request);
// Return proceed_always to switch to auto-edit mode
return { optionId: 'proceed_always' };
}
// Auto-approve all other requests
return { optionId: 'proceed_once' };
},
});
const {
sendRequest,
cleanup,
stderr,
sessionUpdates,
permissionRequests,
agent,
} = setupAcpTest(rig, {
permissionHandler: (request) => {
Comment thread
callmeYe marked this conversation as resolved.
Resolved
// Track all permission requests for later verification
// Auto-approve exit plan mode requests with "proceed_always" to trigger auto-edit mode
if (request.toolCall?.kind === 'switch_mode') {
planModeRequests.push(request);
// Return proceed_always to switch to auto-edit mode
return { optionId: 'proceed_always' };
}
// Auto-approve all other requests
return { optionId: 'proceed_once' };
},
});

try {
// Initialize
Expand Down
1 change: 1 addition & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ describe('qwen serve — capabilities envelope', () => {
'workspace_file_read_cursor',
'workspace_file_write',
'session_approval_mode_control',
'session_reasoning_control',
'workspace_tool_toggle',
Comment on lines +368 to 369

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified — no code change required. The Integration Tests (CLI, No Sandbox) job (integration_cli) is deliberately gated to the merge_group event: .github/workflows/ci.yml:908-928 documents that integration tests run only in the merge queue (not on every PR push) and the job carries if: "${{ !cancelled() && github.event_name == 'merge_group' }}". So the job being skipped on this PR's pushes is expected CI behavior, and the new session_reasoning_control capability assertion will run in the merge queue before the change lands. As evidence the assertion itself is sound, I ran the suite locally after npm run build && npm run bundle: cd integration-tests && QWEN_SANDBOX=false npx vitest run cli/qwen-serve-routes.test.ts — 35/35 tests pass, including the capabilities envelope test that lists session_reasoning_control.

中文说明

已核实——无需代码改动。Integration Tests (CLI, No Sandbox) 任务(integration_cli)有意只在 merge_group 事件触发:.github/workflows/ci.yml:908-928 的注释说明集成测试只在合并队列中运行(不在每次 PR push 时运行),该任务的门控条件是 if: "${{ !cancelled() && github.event_name == 'merge_group' }}"。因此本 PR 的 push 中该任务被跳过属于预期的 CI 行为,新增的 session_reasoning_control 能力断言会在合并队列中、变更合入前运行。为证明断言本身有效,我在本地执行 npm run build && npm run bundle 后运行了该套件:cd integration-tests && QWEN_SANDBOX=false npx vitest run cli/qwen-serve-routes.test.ts —— 35/35 全部通过,包括列出 session_reasoning_control 的 capabilities envelope 测试。

'workspace_skill_toggle',
'workspace_skill_batch_toggle',
Expand Down
114 changes: 114 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14214,6 +14214,120 @@ describe('createAcpSessionBridge', () => {
});
});

describe('setSessionConfigOption', () => {
/** Set up a channel where the agent records setSessionConfigOption calls. */
async function setup(response: Record<string, unknown> = {}) {
const configOptionCalls: Array<{
sessionId: string;
configId: string;
value: string;
}> = [];
const factory: ChannelFactory = async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
const augmented = new Proxy(fakeAgent, {
get(target, prop) {
if (prop === 'setSessionConfigOption') {
return async (req: {
sessionId: string;
configId: string;
value: string;
}) => {
configOptionCalls.push({ ...req });
return response;
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target as any)[prop];
},
});
new AgentSideConnection(() => augmented as Agent, agentStream);
return {
stream: clientStream,
exited: new Promise<
| { exitCode: number | null; signalCode: NodeJS.Signals | null }
| undefined
>(() => {}),
kill: async () => {},
killSync: () => {},
};
};
const bridge = makeBridge({ channelFactory: factory });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
return { bridge, session, configOptionCalls };
}

it('forwards configId and value and overrides body sessionId', async () => {
const { bridge, session, configOptionCalls } = await setup({
configOptions: [],
});
const response = await bridge.setSessionConfigOption(session.sessionId, {
sessionId: 'spoofed',
configId: 'effort',
value: 'medium',
});
expect(response).toEqual({ configOptions: [] });
expect(configOptionCalls[0]).toEqual({
sessionId: session.sessionId,
configId: 'effort',
value: 'medium',
});
await bridge.shutdown();
});

it('publishes settings_changed for model.reasoningPreferences', async () => {
const { bridge, session } = await setup({ configOptions: [] });
const abort = new AbortController();
const iter = bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
});
await bridge.setSessionConfigOption(session.sessionId, {
sessionId: session.sessionId,
configId: 'thinking',
value: 'off',
});
const it = iter[Symbol.asyncIterator]();
const next = await it.next();
expect(next.value?.type).toBe('settings_changed');
expect(next.value?.data).toEqual({
key: 'model.reasoningPreferences',
});
Comment thread
callmeYe marked this conversation as resolved.
Resolved
abort.abort();
await bridge.shutdown();
});

it('rejects unregistered client ids', async () => {
const { bridge, session } = await setup();
await expect(
bridge.setSessionConfigOption(
session.sessionId,
{
sessionId: session.sessionId,
configId: 'effort',
value: 'low',
},
{ clientId: 'client-not-issued' },
),
).rejects.toBeInstanceOf(InvalidClientIdError);
await bridge.shutdown();
});

it('throws SessionNotFoundError for unknown session ids', async () => {
const bridge = makeBridge({
channelFactory: async () => {
throw new Error('factory should not be called');
},
});
await expect(
bridge.setSessionConfigOption('unknown', {
sessionId: 'unknown',
configId: 'thinking',
value: 'off',
}),
).rejects.toBeInstanceOf(SessionNotFoundError);
});
});

describe('executeShellCommand permission policy', () => {
function mockShellExecute(output = 'ok') {
return vi.spyOn(ShellExecutionService, 'execute').mockResolvedValue({
Expand Down
28 changes: 28 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8627,6 +8627,34 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return response;
},

async setSessionConfigOption(sessionId, req, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const originatorClientId = resolveTrustedClientId(
entry,
context?.clientId,
);
const response = await Promise.race([
withTimeout(
entry.connection.setSessionConfigOption({ ...req, sessionId }),
Comment thread
callmeYe marked this conversation as resolved.
Resolved
initTimeoutMs,
'setSessionConfigOption',
),
getTransportClosedReject(entry),
]);
// Thinking/effort roundtrips persist `model.reasoningPreferences`
// child-side; broadcast settings_changed like setSessionModel does for
// `model.name` so other attached clients reload their settings caches.
if (req.configId === 'thinking' || req.configId === 'effort') {
broadcastWorkspaceEvent({
type: 'settings_changed',
data: { key: 'model.reasoningPreferences' },
...(originatorClientId ? { originatorClientId } : {}),
});
}
return response;
},

async setSessionLanguage(sessionId, params, context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
Expand Down
9 changes: 9 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import type {
PromptResponse,
RequestPermissionResponse,
ResumeSessionResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModelRequest,
SetSessionModelResponse,
SessionUpdate,
Expand Down Expand Up @@ -1545,6 +1547,13 @@ export interface AcpSessionBridge {
context?: BridgeClientRequestContext,
): Promise<SetSessionModelResponse>;

/** Update one ACP session configuration option. */
setSessionConfigOption(
sessionId: string,
req: SetSessionConfigOptionRequest,
context?: BridgeClientRequestContext,
): Promise<SetSessionConfigOptionResponse>;

/**
* Switch UI language and optionally LLM output language for a live
* session, then broadcast a `language_changed` event. When
Expand Down
17 changes: 17 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,21 @@ export interface ServeWorkspaceProviderCurrent {
visionModelId?: string;
}

export type ServeModelReasoningEffort =
| 'low'
| 'medium'
| 'high'
| 'xhigh'
| 'max';

export interface ServeModelReasoningControls {
thinking?: { defaultEnabled: boolean };
effort?: {
supported: ServeModelReasoningEffort[];
default: ServeModelReasoningEffort;
};
}

export interface ServeWorkspaceProviderModel {
modelId: string;
baseModelId: string;
Expand All @@ -513,6 +528,7 @@ export interface ServeWorkspaceProviderModel {
};
baseUrl?: string;
envKey?: string;
reasoningControls?: ServeModelReasoningControls;
isCurrent: boolean;
isRuntime: boolean;
}
Expand All @@ -531,6 +547,7 @@ export interface ServeWorkspaceProvidersStatus {
acpChannelLive?: boolean;
current?: ServeWorkspaceProviderCurrent;
approvalMode?: string;
modelConfigScope?: 'workspace' | 'user';
providers: ServeWorkspaceProviderStatus[];
errors?: ServeStatusCell[];
}
Expand Down
Loading
Loading