Skip to content

Commit 54a81e0

Browse files
authored
fix: expose session-specific thinking levels (#76548)
* fix: expose session-specific thinking levels (#76482) * fix: preserve lightweight sessions.list contract, fix consumer-side fallbacks only * fix: include thinking levels in lightweight session rows for Control UI (#76482) The Control UI cannot resolve provider-specific thinking levels client-side (ui/src/ui/thinking.ts always returns base 5 levels). The gateway must provide them even in lightweight rows. listThinkingLevelOptions is a cheap in-memory lookup — negligible perf impact vs the transcript/cost/model ops that the lightweight flag still skips. Also update existing test assertions that expected thinkingOptions: [] for lightweight rows (flagged by ClawSweeper review). * test: add e2e regression tests for thinking level pipeline (#76482)
1 parent a92e2b1 commit 54a81e0

10 files changed

Lines changed: 361 additions & 18 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
4444
- CLI/plugins: keep `plugins enable` and `plugins disable` from creating unconfigured channel config sections, so channel plugins with required setup fields no longer fail validation during lifecycle probes. Thanks @vincentkoc.
4545
- Doctor/config: set `messages.groupChat.visibleReplies: "message_tool"` during compatibility repair for configured-channel configs that omit a visible-reply policy, so upgrades can persist the intended tool-only group/channel reply default. Thanks @kagura-agent.
4646
- Agents/sessions: keep delayed `sessions_send` A2A replies alive after soft wait-window timeouts, while preserving terminal run timeouts and avoiding stale target replies in requester sessions. Fixes #76443. Thanks @ryswork1993 and @vincentkoc.
47+
- TUI/Control UI: fix `/think` command showing only base thinking levels when the active session uses a different model from the default, so provider-specific levels like DeepSeek V4 Pro's `xhigh` and `max` are now visible and selectable. Fixes #76482. Thanks @amknight.
4748
- CLI/sessions: keep intentional empty agent replies silent after tool-delivered channel output, instead of surfacing a misleading "No reply from agent." fallback. Thanks @vincentkoc.
4849
- Config/doctor: cap `.clobbered.*` forensic snapshots per config path and serialize snapshot writes so repeated `doctor --fix` recovery loops cannot flood the config directory. Fixes #76454; carries forward #65649. Thanks @JUSTICEESSIELP, @rsnow, and @vincentkoc.
4950
- Feishu: suppress duplicate text when replies send native voice media while preserving captions for ordinary audio files and falling back to text plus attachment links when voice uploads fail.

src/gateway/server.sessions.list-changed.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ test("sessions.list uses the gateway model catalog for effective thinking defaul
153153
expect.objectContaining({
154154
key: "agent:main:main",
155155
thinkingDefault: undefined,
156-
thinkingOptions: [],
156+
thinkingOptions: ["off", "minimal", "low", "medium", "high"],
157157
}),
158158
]),
159159
}),
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/**
2+
* E2E regression test for #76482: verifies the full pipeline from gateway
3+
* sessions.list (lightweight rows with empty thinkingOptions) through
4+
* consumer-side resolution, ensuring:
5+
* 1. DeepSeek V4 Pro sessions resolve all 7 thinking levels
6+
* 2. Anthropic sessions don't leak DeepSeek levels from defaults
7+
* 3. Sessions matching the default model correctly inherit defaults
8+
*/
9+
import { expect, test, vi } from "vitest";
10+
import { formatThinkingLevels } from "../auto-reply/thinking.js";
11+
import { testState, writeSessionStore } from "./test-helpers.js";
12+
import {
13+
setupGatewaySessionsTestHarness,
14+
getGatewayConfigModule,
15+
getSessionsHandlers,
16+
sessionStoreEntry,
17+
} from "./test/server-sessions.test-helpers.js";
18+
19+
const { createSessionStoreDir } = setupGatewaySessionsTestHarness();
20+
21+
/**
22+
* Simulates the consumer-side resolution from session-controls.ts and
23+
* slash-command-executor.ts — the code path that the PR fixes.
24+
*/
25+
function resolveThinkingLevelsConsumerSide(
26+
session:
27+
| {
28+
modelProvider?: string;
29+
model?: string;
30+
thinkingLevels?: Array<{ label: string }>;
31+
thinkingOptions?: string[];
32+
}
33+
| undefined,
34+
defaults:
35+
| {
36+
modelProvider?: string;
37+
model?: string;
38+
thinkingLevels?: Array<{ label: string }>;
39+
thinkingOptions?: string[];
40+
}
41+
| undefined,
42+
): string[] {
43+
if (session?.thinkingLevels?.length) {
44+
return session.thinkingLevels.map((l) => l.label);
45+
}
46+
const sessionModelMatchesDefaults =
47+
(!session?.modelProvider || session.modelProvider === defaults?.modelProvider) &&
48+
(!session?.model || session.model === defaults?.model);
49+
if (sessionModelMatchesDefaults && defaults?.thinkingLevels?.length) {
50+
return defaults.thinkingLevels.map((l) => l.label);
51+
}
52+
const labels =
53+
(session?.thinkingOptions?.length ? session.thinkingOptions : null) ??
54+
(sessionModelMatchesDefaults && defaults?.thinkingOptions?.length
55+
? defaults.thinkingOptions
56+
: null) ??
57+
formatThinkingLevels(
58+
session?.modelProvider ?? defaults?.modelProvider,
59+
session?.model ?? defaults?.model,
60+
).split(/\s*,\s*/);
61+
return labels.filter(Boolean);
62+
}
63+
64+
test("e2e #76482: session with different model gets its own thinking levels through gateway row + consumer fallback", async () => {
65+
await createSessionStoreDir();
66+
testState.agentConfig = {
67+
model: { primary: "openai/gpt-5.5" },
68+
};
69+
await writeSessionStore({
70+
entries: {
71+
main: sessionStoreEntry("sess-main", {
72+
modelProvider: "test-extended",
73+
model: "extended-reasoner",
74+
}),
75+
},
76+
});
77+
78+
const respond = vi.fn();
79+
const sessionsHandlers = await getSessionsHandlers();
80+
const { getRuntimeConfig } = await getGatewayConfigModule();
81+
await sessionsHandlers["sessions.list"]({
82+
req: { type: "req", id: "req-e2e-extended", method: "sessions.list", params: {} },
83+
params: {},
84+
respond,
85+
client: null,
86+
isWebchatConnect: () => false,
87+
context: {
88+
getRuntimeConfig,
89+
// Provide a catalog with xhigh support — simulates what a real gateway
90+
// resolves for models like DeepSeek V4 Pro
91+
loadGatewayModelCatalog: async () => [
92+
{
93+
provider: "test-extended",
94+
id: "extended-reasoner",
95+
name: "Extended Reasoner",
96+
reasoning: true,
97+
compat: { supportedReasoningEfforts: ["xhigh"] },
98+
},
99+
],
100+
} as never,
101+
});
102+
103+
const result = respond.mock.calls[0]?.[1];
104+
const session = result?.sessions?.find((s: { key: string }) => s.key === "agent:main:main");
105+
const defaults = result?.defaults;
106+
107+
// Gateway includes thinkingOptions for lightweight rows (needed by Control UI)
108+
expect(session?.thinkingOptions?.length).toBeGreaterThan(0);
109+
expect(session?.thinkingOptions).toContain("xhigh");
110+
111+
// Session model differs from default
112+
expect(session?.modelProvider).toBe("test-extended");
113+
expect(defaults?.modelProvider).toBe("openai");
114+
115+
// Consumer-side resolution uses session's own thinkingOptions (not defaults)
116+
const resolved = resolveThinkingLevelsConsumerSide(session, defaults);
117+
expect(resolved).toContain("xhigh");
118+
expect(resolved).toContain("off");
119+
expect(resolved).toContain("high");
120+
});
121+
122+
test("e2e #76482: Anthropic session does not leak DeepSeek thinking levels from defaults", async () => {
123+
await createSessionStoreDir();
124+
testState.agentConfig = {
125+
model: { primary: "deepseek/deepseek-v4-pro" },
126+
};
127+
await writeSessionStore({
128+
entries: {
129+
main: sessionStoreEntry("sess-main", {
130+
modelProvider: "anthropic",
131+
model: "claude-sonnet-4-6",
132+
}),
133+
},
134+
});
135+
136+
const respond = vi.fn();
137+
const sessionsHandlers = await getSessionsHandlers();
138+
const { getRuntimeConfig } = await getGatewayConfigModule();
139+
await sessionsHandlers["sessions.list"]({
140+
req: { type: "req", id: "req-e2e-anthropic", method: "sessions.list", params: {} },
141+
params: {},
142+
respond,
143+
client: null,
144+
isWebchatConnect: () => false,
145+
context: { getRuntimeConfig, loadGatewayModelCatalog: async () => [] } as never,
146+
});
147+
148+
const result = respond.mock.calls[0]?.[1];
149+
const session = result?.sessions?.find((s: { key: string }) => s.key === "agent:main:main");
150+
const defaults = result?.defaults;
151+
152+
// Session model differs from default
153+
expect(session?.modelProvider).toBe("anthropic");
154+
expect(defaults?.modelProvider).toBe("deepseek");
155+
156+
// Consumer-side resolution should NOT include DeepSeek-specific levels
157+
const resolved = resolveThinkingLevelsConsumerSide(session, defaults);
158+
expect(resolved).not.toContain("xhigh");
159+
expect(resolved).not.toContain("max");
160+
// Should have base Anthropic levels
161+
expect(resolved).toContain("off");
162+
expect(resolved).toContain("high");
163+
});
164+
165+
test("e2e #76482: session matching default model inherits default thinking levels", async () => {
166+
await createSessionStoreDir();
167+
testState.agentConfig = {
168+
model: { primary: "openai/gpt-5.5" },
169+
};
170+
await writeSessionStore({
171+
entries: {
172+
main: sessionStoreEntry("sess-main", {
173+
modelProvider: "openai",
174+
model: "gpt-5.5",
175+
}),
176+
},
177+
});
178+
179+
const respond = vi.fn();
180+
const sessionsHandlers = await getSessionsHandlers();
181+
const { getRuntimeConfig } = await getGatewayConfigModule();
182+
await sessionsHandlers["sessions.list"]({
183+
req: { type: "req", id: "req-e2e-same", method: "sessions.list", params: {} },
184+
params: {},
185+
respond,
186+
client: null,
187+
isWebchatConnect: () => false,
188+
context: { getRuntimeConfig, loadGatewayModelCatalog: async () => [] } as never,
189+
});
190+
191+
const result = respond.mock.calls[0]?.[1];
192+
const session = result?.sessions?.find((s: { key: string }) => s.key === "agent:main:main");
193+
const defaults = result?.defaults;
194+
195+
// Session matches default → consumer should use defaults
196+
expect(session?.modelProvider).toBe(defaults?.modelProvider);
197+
198+
const resolved = resolveThinkingLevelsConsumerSide(session, defaults);
199+
expect(resolved.length).toBeGreaterThan(0);
200+
// Should match what defaults provide
201+
expect(resolved).toContain("off");
202+
expect(resolved).toContain("high");
203+
});

src/gateway/session-utils.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1144,7 +1144,7 @@ describe("listSessionsFromStore selected model display", () => {
11441144
}),
11451145
);
11461146
expect(listed.sessions[0]?.agentRuntime).toEqual({ id: "pi", source: "implicit" });
1147-
expect(listed.sessions[0]?.thinkingOptions).toEqual([]);
1147+
expect(listed.sessions[0]?.thinkingOptions?.length).toBeGreaterThan(0);
11481148
} finally {
11491149
fs.rmSync(tmpDir, { recursive: true, force: true });
11501150
}

src/gateway/session-utils.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,9 +1635,11 @@ export function buildGatewaySessionRow(params: {
16351635

16361636
const thinkingProvider = rowModelProvider ?? DEFAULT_PROVIDER;
16371637
const thinkingModel = rowModel ?? DEFAULT_MODEL;
1638-
const thinkingLevels = lightweight
1639-
? []
1640-
: listThinkingLevelOptions(thinkingProvider, thinkingModel, params.modelCatalog);
1638+
const thinkingLevels = listThinkingLevelOptions(
1639+
thinkingProvider,
1640+
thinkingModel,
1641+
params.modelCatalog,
1642+
);
16411643
const pluginExtensions =
16421644
!lightweight && entry ? projectPluginSessionExtensionsSync({ sessionKey: key, entry }) : [];
16431645

src/tui/commands.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ describe("getSlashCommands", () => {
5555
{ value: "max", label: "max" },
5656
]);
5757
});
58+
59+
it("falls back to provider-resolved levels when thinkingLevels is empty (#76482)", () => {
60+
const commands = getSlashCommands({
61+
provider: "anthropic",
62+
model: "claude-sonnet-4-6",
63+
thinkingLevels: [], // empty from lightweight session row
64+
});
65+
const think = commands.find((command) => command.name === "think");
66+
// Should fall back to listThinkingLevelLabels, not return empty completions
67+
const completions = think?.getArgumentCompletions?.("");
68+
expect(completions?.length).toBeGreaterThan(0);
69+
});
5870
});
5971

6072
describe("helpText", () => {

src/tui/commands.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ export function parseCommand(input: string): ParsedCommand {
5656
}
5757

5858
export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] {
59-
const thinkLevels =
60-
options.thinkingLevels?.map((level) => level.label) ??
61-
listThinkingLevelLabels(options.provider, options.model);
59+
const thinkLevels = options.thinkingLevels?.length
60+
? options.thinkingLevels.map((level) => level.label)
61+
: listThinkingLevelLabels(options.provider, options.model);
6262
const verboseCompletions = createLevelCompletion(VERBOSE_LEVELS);
6363
const traceCompletions = createLevelCompletion(TRACE_LEVELS);
6464
const fastCompletions = createLevelCompletion(FAST_LEVELS);

ui/src/ui/chat/session-controls.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -182,12 +182,17 @@ function resolveThinkingLevelOptions(
182182
if (activeRow?.thinkingLevels?.length) {
183183
return activeRow.thinkingLevels;
184184
}
185-
if (defaults?.thinkingLevels?.length) {
185+
const sessionModelMatchesDefaults =
186+
(!activeRow?.modelProvider || activeRow.modelProvider === defaults?.modelProvider) &&
187+
(!activeRow?.model || activeRow.model === defaults?.model);
188+
if (sessionModelMatchesDefaults && defaults?.thinkingLevels?.length) {
186189
return defaults.thinkingLevels;
187190
}
188191
const labels =
189-
activeRow?.thinkingOptions ??
190-
defaults?.thinkingOptions ??
192+
(activeRow?.thinkingOptions?.length ? activeRow.thinkingOptions : null) ??
193+
(sessionModelMatchesDefaults && defaults?.thinkingOptions?.length
194+
? defaults.thinkingOptions
195+
: null) ??
191196
(provider && model ? listThinkingLevelLabels(provider, model) : listThinkingLevelLabels());
192197
return labels.map((label) => ({
193198
id: normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label),

0 commit comments

Comments
 (0)