fix(control-ui): autocomplete model thinking levels (#123507)

* fix(control-ui): autocomplete model thinking levels

* fix(control-ui): keep thinking options model-current

* fix(control-ui): execute catalog thinking levels

* fix(control-ui): close stale think picker

---------

Co-authored-by: Jesse Merhi <openclaw@users.noreply.github.com>
This commit is contained in:
Jesse Merhi
2026-08-21 23:02:33 +10:00
committed by GitHub
parent e66d7c5cac
commit efbcb57569
11 changed files with 344 additions and 24 deletions
@@ -0,0 +1,126 @@
// Control UI E2E proves model-aware /think completion in the rendered composer.
import fs from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "Control UI thinking argument completion",
});
const VIEWPORTS = [
{ name: "mobile", width: 390, height: 844 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "desktop", width: 1440, height: 900 },
] as const;
suite.define(() => {
it.each(VIEWPORTS)(
"opens the active model's thinking levels above the composer ($name)",
async (viewport) => {
await suite.withPage({ viewport }, async ({ page }) => {
const browserErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") {
browserErrors.push(message.text());
}
});
page.on("pageerror", (error) => browserErrors.push(error.message));
const gateway = await installMockGateway(page, {
models: [
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
provider: "openai",
thinkingLevels: [
{ id: "off", label: "off" },
{ id: "minimal", label: "minimal" },
{ id: "low", label: "low" },
{ id: "medium", label: "medium" },
{ id: "high", label: "high" },
{ id: "xhigh", label: "xhigh" },
{ id: "max", label: "max" },
{ id: "ultra", label: "ultra" },
],
},
],
methodResponses: {
"sessions.list": {
count: 1,
defaults: {
contextTokens: 200_000,
model: "gpt-5.6-sol",
modelProvider: "openai",
},
path: "",
sessions: [
{
key: "main",
kind: "direct",
model: "gpt-5.6-sol",
modelProvider: "openai",
updatedAt: Date.now(),
},
],
ts: Date.now(),
},
},
});
await page.goto(`${suite.server.baseUrl}chat`);
await gateway.waitForRequest("chat.startup");
const composer = page.locator(".agent-chat__composer-combobox textarea");
await composer.waitFor({ state: "visible" });
await expect.poll(() => composer.isEnabled()).toBe(true);
await composer.fill("/think");
await composer.press("Tab");
const picker = page.locator(".slash-menu[role='listbox']");
await picker.waitFor({ state: "visible" });
await expect.poll(() => composer.inputValue()).toBe("/think ");
await expect
.poll(() => picker.getByRole("option").locator(".slash-menu-name").allTextContents())
.toEqual(["default", "off", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
const [pickerBox, inputBox] = await Promise.all([
picker.boundingBox(),
page.locator(".agent-chat__input").boundingBox(),
]);
expect(pickerBox).not.toBeNull();
expect(inputBox).not.toBeNull();
expect((pickerBox?.y ?? 0) + (pickerBox?.height ?? 0)).toBeLessThanOrEqual(
(inputBox?.y ?? 0) + 1,
);
expect(pickerBox?.x ?? -1).toBeGreaterThanOrEqual(0);
expect((pickerBox?.x ?? 0) + (pickerBox?.width ?? 0)).toBeLessThanOrEqual(viewport.width);
expect(pickerBox?.y ?? -1).toBeGreaterThanOrEqual(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
viewport.width,
);
expect(browserErrors).toEqual([]);
await composer.press("ArrowUp");
await composer.press("Tab");
await expect.poll(() => composer.inputValue()).toBe("/think ultra");
await composer.press("Enter");
const patchRequest = await gateway.waitForRequest("sessions.patch");
expect(patchRequest.params).toMatchObject({
key: "main",
thinkingLevel: "ultra",
});
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
if (artifactDir) {
await fs.mkdir(artifactDir, { recursive: true });
await page.screenshot({
path: path.join(artifactDir, `think-arguments-${viewport.name}.png`),
fullPage: true,
});
}
});
},
);
});
+31 -5
View File
@@ -43,16 +43,39 @@ export type ChatThinkingSelectState = {
function resolveThinkingLevelOptionsForSession(
session: ChatThinkingTarget | undefined,
defaults: ThinkingSessionDefaults,
catalog: readonly ModelCatalogEntry[] = [],
fallbackLabels?: readonly string[],
): GatewayThinkingLevelOption[] {
const { provider, model } = resolveThinkingTargetModel({ defaults, session });
return resolveThinkingLevelOptions({ catalog: [], defaults, model, provider, session });
return resolveThinkingLevelOptions({
catalog,
defaults,
fallbackLabels,
model,
provider,
session,
});
}
export function resolveThinkingCommandArgOptionsForSession(
session: ChatThinkingTarget | undefined,
defaults?: SessionsListResult["defaults"],
catalog: readonly ModelCatalogEntry[] = [],
): string[] {
const options = resolveThinkingLevelOptionsForSession(session, defaults, catalog, []).map(
(level) => normalizeThinkingOptionValue(level.id),
);
return options.length > 0
? ["default", ...new Set(options.filter((option) => option && option !== "default"))]
: [];
}
export function formatThinkingCommandOptionsForSession(
session: ChatThinkingTarget | undefined,
defaults?: SessionsListResult["defaults"],
catalog: readonly ModelCatalogEntry[] = [],
): string {
const options = resolveThinkingLevelOptionsForSession(session, defaults)
const options = resolveThinkingLevelOptionsForSession(session, defaults, catalog)
.map((level) => level.label)
.join(", ");
return options.split(", ").includes("default") ? options : `default, ${options}`;
@@ -62,13 +85,14 @@ export function resolveThinkingLevelInput(
rawLevel: string,
session: ChatThinkingTarget | undefined,
defaults: ThinkingSessionDefaults,
catalog: readonly ModelCatalogEntry[] = [],
): string | undefined {
const normalized = normalizeThinkLevel(rawLevel);
if (normalized) {
return normalized;
}
const rawKey = normalizeLowercaseStringOrEmpty(rawLevel);
return resolveThinkingLevelOptionsForSession(session, defaults)
return resolveThinkingLevelOptionsForSession(session, defaults, catalog)
.map((option) => ({
id: normalizeThinkLevel(option.id) ?? normalizeLowercaseStringOrEmpty(option.id),
label: normalizeLowercaseStringOrEmpty(option.label),
@@ -80,8 +104,9 @@ export function isThinkingLevelOptionForSession(
session: ChatThinkingTarget | undefined,
defaults: ThinkingSessionDefaults,
level: string,
catalog: readonly ModelCatalogEntry[] = [],
): boolean {
return resolveThinkingLevelOptionsForSession(session, defaults).some((option) => {
return resolveThinkingLevelOptionsForSession(session, defaults, catalog).some((option) => {
const id = normalizeThinkLevel(option.id) ?? normalizeLowercaseStringOrEmpty(option.id);
return id === level || normalizeThinkLevel(option.label) === level;
});
@@ -167,6 +192,7 @@ function resolveThinkingCatalogEntry(
function resolveThinkingLevelOptions(params: {
catalog: readonly ModelCatalogEntry[];
defaults: ThinkingSessionDefaults;
fallbackLabels?: readonly string[];
hideUnsupportedOffOnly?: boolean;
model: string | null;
provider: string | null;
@@ -202,7 +228,7 @@ function resolveThinkingLevelOptions(params: {
return [];
}
}
const labels = explicitLabels ?? BASE_THINKING_LEVELS;
const labels = explicitLabels ?? params.fallbackLabels ?? BASE_THINKING_LEVELS;
return labels.map((label) => ({
id: normalizeThinkLevel(label) ?? normalizeLowercaseStringOrEmpty(label),
label,
@@ -1099,6 +1099,50 @@ describe("executeSlashCommand directives", () => {
});
});
it("accepts a thinking level advertised only by the active model catalog", async () => {
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method === "sessions.list") {
return {
sessions: [
row("agent:main:main", {
model: "gpt-5.6-sol",
modelProvider: "openai",
}),
],
};
}
if (method === "sessions.patch") {
return { ok: true, ...((payload ?? {}) as object) };
}
throw new Error(`unexpected method: ${method}`);
});
const result = await executeSlashCommand(
createTestGatewayClient(request),
"agent:main:main",
"think",
"ultra",
{
chatModelCatalog: [
{
id: "gpt-5.6-sol",
name: "GPT-5.6 Sol",
provider: "openai",
reasoning: true,
thinkingLevels: [{ id: "ultra", label: "ultra" }],
},
],
},
);
expect(result.content).toBe(t("chat.commandResults.thinking.set", { level: "**ultra**" }));
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "agent:main:main",
thinkingLevel: "ultra",
});
expectNoRequestCall(request, "models.list");
});
it("clears thinking override for /think default", async () => {
const request = vi.fn(async (method: string, payload?: unknown) => {
if (method === "sessions.patch") {
+6 -5
View File
@@ -375,7 +375,7 @@ async function executeThink(
t("chat.commandResults.thinking.current", {
level: resolveCurrentThinkingLevel(session, defaults, models),
}),
formatThinkingCommandOptionsForSession(session, defaults),
formatThinkingCommandOptionsForSession(session, defaults, models),
),
};
} catch (err) {
@@ -405,20 +405,21 @@ async function executeThink(
try {
const { session, defaults } = await loadCurrentSessionState(context, sessionKey);
const level = resolveThinkingLevelInput(rawLevel, session, defaults);
const modelCatalog = context.chatModelCatalog ?? context.modelCatalog ?? [];
const level = resolveThinkingLevelInput(rawLevel, session, defaults, modelCatalog);
if (!level) {
return {
content: t("chat.commandResults.thinking.unrecognized", {
level: rawLevel,
options: formatThinkingCommandOptionsForSession(session, defaults),
options: formatThinkingCommandOptionsForSession(session, defaults, modelCatalog),
}),
};
}
if (!isThinkingLevelOptionForSession(session, defaults, level)) {
if (!isThinkingLevelOptionForSession(session, defaults, level, modelCatalog)) {
return {
content: t("chat.commandResults.thinking.unsupported", {
level: rawLevel,
options: formatThinkingCommandOptionsForSession(session, defaults),
options: formatThinkingCommandOptionsForSession(session, defaults, modelCatalog),
}),
};
}
@@ -18,6 +18,8 @@ export function createComposerProps(overrides: Partial<ComposerProps> = {}): Com
stream: null,
queue: [],
draft: "",
modelCatalog: [],
modelSwitching: false,
sessions: null,
assistantName: "OpenClaw",
onDraftChange: vi.fn(),
+2
View File
@@ -340,6 +340,8 @@ export class ChatPane extends ChatPaneLayoutRender {
sendShortcut: state.settings.chatSendShortcut,
followUpMode: state.chatFollowUpMode,
draft: state.chatMessage,
modelCatalog: state.chatModelCatalog,
modelSwitching: Boolean(state.chatModelSwitchPromises[state.sessionKey]),
queue: state.chatQueue,
queuedOutboxCount: state.chatQueue.filter((item) => !item.pendingRunId).length,
realtimeTalkActive: state.realtimeTalkActive,
+86 -3
View File
@@ -673,6 +673,8 @@ function createChatProps(overrides: Partial<ChatProps> = {}): ChatProps {
streamStartedAt: null,
assistantAvatarUrl: null,
draft: "",
modelCatalog: [],
modelSwitching: false,
queue: [],
realtimeTalkActive: false,
realtimeTalkStatus: "idle",
@@ -3456,22 +3458,24 @@ describe("chat slash menu accessibility", () => {
...overrides
}: Partial<ChatProps> = {}) {
let draft = "";
let currentOverrides = overrides;
const container = document.createElement("div");
const onDraftChange = vi.fn((next: string) => {
draft = next;
observeDraftChange?.(next);
});
const renderCurrent = () => {
const renderCurrent = (nextOverrides: Partial<ChatProps> = {}) => {
currentOverrides = { ...currentOverrides, ...nextOverrides };
renderChatInto(container, {
draft,
getDraft: () => draft,
onDraftChange,
onRequestUpdate: renderCurrent,
...overrides,
...currentOverrides,
});
};
renderCurrent();
return { container };
return { container, renderCurrent };
}
function createSlashRerenderHarness() {
@@ -4195,6 +4199,85 @@ describe("chat slash menu accessibility", () => {
expect(listbox?.querySelector(`#${activeId}`)?.getAttribute("aria-selected")).toBe("true");
});
it("opens model-supported thinking arguments after tab-completing /think", () => {
const sessions = createSessionsListResult({
model: "gpt-5.6-sol",
modelProvider: "openai",
});
const session = expectDefined(sessions.sessions[0], "active session");
session.thinkingLevels = [
{ id: "off", label: "off" },
{ id: "minimal", label: "minimal" },
{ id: "low", label: "low" },
{ id: "medium", label: "medium" },
{ id: "high", label: "high" },
{ id: "xhigh", label: "xhigh" },
{ id: "max", label: "max" },
{ id: "ultra", label: "ultra" },
];
const { container } = createReactiveDraftHarness({ sessions });
inputDraft(container, "/think");
keydownComposer(container, "Tab");
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("/think ");
expect(
Array.from(container.querySelectorAll<HTMLElement>(".slash-menu [role='option']")).map(
(option) => option.querySelector(".slash-menu-name")?.textContent?.trim(),
),
).toEqual(["default", "off", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
});
it("suppresses thinking arguments while the active model is switching", () => {
const sessions = createSessionsListResult({
model: "gpt-5.6-sol",
modelProvider: "openai",
});
const session = expectDefined(sessions.sessions[0], "active session");
session.thinkingLevels = [
{ id: "low", label: "low" },
{ id: "high", label: "high" },
];
const { container } = createReactiveDraftHarness({ modelSwitching: true, sessions });
inputDraft(container, "/think");
keydownComposer(container, "Tab");
expect(container.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("/think ");
expect(container.querySelector(".slash-menu")).toBeNull();
});
it("closes open thinking arguments when the active model starts switching", () => {
const sessions = createSessionsListResult({
model: "gpt-5.6-sol",
modelProvider: "openai",
});
const session = expectDefined(sessions.sessions[0], "active session");
session.thinkingLevels = [
{ id: "low", label: "low" },
{ id: "high", label: "high" },
];
const { container, renderCurrent } = createReactiveDraftHarness({ sessions });
inputDraft(container, "/think");
keydownComposer(container, "Tab");
expect(container.querySelector(".slash-menu")).not.toBeNull();
expect(
container
.querySelector<HTMLTextAreaElement>("textarea")
?.getAttribute("aria-activedescendant"),
).toBe("chat-single-slash-option-arg-think-default");
renderCurrent({ modelSwitching: true });
expect(container.querySelector(".slash-menu")).toBeNull();
expect(
container
.querySelector<HTMLTextAreaElement>("textarea")
?.hasAttribute("aria-activedescendant"),
).toBe(false);
});
it("clears active descendant when suggestions close", () => {
const harness = createSlashRerenderHarness();
let container = harness.inputAndRender(harness.container, "/");
+5 -1
View File
@@ -13,7 +13,7 @@ import type {
ControlUiSessionPullRequest,
} from "../../../../src/gateway/control-ui-contract.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { GatewaySessionRow, SessionsListResult } from "../../api/types.ts";
import type { GatewaySessionRow, ModelCatalogEntry, SessionsListResult } from "../../api/types.ts";
import type { ExecApprovalDecision, ExecApprovalRequest } from "../../app/exec-approval.ts";
import type { QuestionPrompt } from "../../app/question-prompt.ts";
import type { ChatSendShortcut } from "../../app/settings.ts";
@@ -125,6 +125,8 @@ export type ChatProps = ChatTaskSuggestionTrayProps &
runOutputTokens?: number | null;
assistantAvatarUrl?: string | null;
draft: string;
modelCatalog: readonly ModelCatalogEntry[];
modelSwitching: boolean;
queue: ChatQueueItem[];
queuedOutboxCount?: number;
realtimeTalkActive?: boolean;
@@ -408,6 +410,8 @@ export function renderChat(props: ChatProps) {
stream: props.stream,
queue: props.queue,
draft: props.draft,
modelCatalog: props.modelCatalog,
modelSwitching: props.modelSwitching,
sessions: props.sessions,
toolOverrides: props.toolOverrides,
capabilityMenu: props.capabilityMenu,
@@ -9,6 +9,8 @@ import {
type SlashCommandCategory,
type SlashCommandDef,
} from "../../../lib/chat/commands.ts";
import { resolveThinkingCommandArgOptionsForSession } from "../../../lib/chat/thinking.ts";
import { areUiSessionKeysEquivalent } from "../../../lib/sessions/session-key.ts";
import { paneDomId } from "./chat-composer-dom.ts";
import { commitComposerDraft, getChatComposerState } from "./chat-composer-state.ts";
import type { ChatComposerProps, ChatComposerState } from "./chat-composer-types.ts";
@@ -39,6 +41,26 @@ function closeSlashMenuIfNeeded(state: ChatComposerState, requestUpdate: () => v
requestUpdate();
}
function resolveSlashCommandArgOptions(
command: SlashCommandDef,
props: ChatComposerProps,
): string[] {
if (command.key !== "think") {
return command.argOptions ?? [];
}
if (props.modelSwitching) {
return [];
}
const session = props.sessions?.sessions.find((row) =>
areUiSessionKeysEquivalent(row.key, props.sessionKey),
);
return resolveThinkingCommandArgOptionsForSession(
session,
props.sessions?.defaults,
props.modelCatalog,
);
}
function requestSlashCommandRefresh(
value: string,
props: ChatComposerProps,
@@ -85,10 +107,11 @@ export function updateSlashMenu(
return;
}
const cmd = SLASH_COMMANDS.find((entry) => entry.name === cmdName);
if (cmd?.argOptions?.length) {
const argOptions = cmd ? resolveSlashCommandArgOptions(cmd, props) : [];
if (cmd && argOptions.length > 0) {
const filtered = argFilter
? cmd.argOptions.filter((arg) => arg.toLowerCase().startsWith(argFilter))
: cmd.argOptions;
? argOptions.filter((arg) => arg.toLowerCase().startsWith(argFilter))
: argOptions;
if (filtered.length > 0) {
state.slashMenuMode = "args";
state.slashMenuCommand = cmd;
@@ -129,11 +152,12 @@ export function selectSlashCommand(
requestUpdate: () => void,
) {
const state = getChatComposerState(props.paneId);
if (cmd.argOptions?.length) {
const argOptions = resolveSlashCommandArgOptions(cmd, props);
if (argOptions.length > 0) {
commitComposerDraft(props, `/${cmd.name} `);
state.slashMenuMode = "args";
state.slashMenuCommand = cmd;
state.slashMenuArgItems = cmd.argOptions;
state.slashMenuArgItems = argOptions;
state.slashMenuOpen = true;
state.slashMenuIndex = 0;
state.slashMenuItems = [];
@@ -158,11 +182,12 @@ export function tabCompleteSlashCommand(
requestUpdate: () => void,
) {
const state = getChatComposerState(props.paneId);
if (cmd.argOptions?.length) {
const argOptions = resolveSlashCommandArgOptions(cmd, props);
if (argOptions.length > 0) {
commitComposerDraft(props, `/${cmd.name} `);
state.slashMenuMode = "args";
state.slashMenuCommand = cmd;
state.slashMenuArgItems = cmd.argOptions;
state.slashMenuArgItems = argOptions;
state.slashMenuOpen = true;
state.slashMenuIndex = 0;
state.slashMenuItems = [];
@@ -367,10 +392,10 @@ export function renderSlashMenu(
</span>
<span class="slash-menu-trailing">
<span class="slash-menu-desc">${getSlashCommandDescription(cmd)}</span>
${cmd.argOptions?.length
${resolveSlashCommandArgOptions(cmd, props).length
? html`<span class="slash-menu-badge"
>${t("chat.commands.optionCount", {
count: String(cmd.argOptions.length),
count: String(resolveSlashCommandArgOptions(cmd, props).length),
})}</span
>`
: nothing}
@@ -1,7 +1,7 @@
import type { ProgressCard } from "@openclaw/gateway-protocol";
import type { TemplateResult, nothing } from "lit";
import type { GatewayBrowserClient } from "../../../api/gateway.ts";
import type { SessionsListResult } from "../../../api/types.ts";
import type { ModelCatalogEntry, SessionsListResult } from "../../../api/types.ts";
import type { QuestionPrompt } from "../../../app/question-prompt.ts";
import type { ChatSendShortcut } from "../../../app/settings.ts";
import type { ChatQueueItem } from "../../../lib/chat/chat-types.ts";
@@ -92,6 +92,8 @@ export type ChatComposerProps = ChatAttachmentControlsProps & {
stream: string | null;
queue: ChatQueueItem[];
draft: string;
modelCatalog: readonly ModelCatalogEntry[];
modelSwitching: boolean;
sessions: SessionsListResult | null;
toolOverrides?: SessionToolOverrides;
capabilityMenu?: CapabilityMenuProps;
@@ -31,6 +31,7 @@ import {
getActiveSlashMenuOptionId,
getActiveSlashMenuOptionLabel,
isSlashMenuVisible,
resetSlashMenuState,
updateSlashMenu,
} from "./chat-composer-slash-menu.ts";
import {
@@ -513,6 +514,10 @@ export function renderChatComposer(props: ChatComposerProps) {
?.getVideoTracks?.()[0]
?.getSettings?.().facingMode;
const mirrorCameraPreview = cameraFacingMode !== "environment";
if (props.modelSwitching && state.slashMenuCommand?.key === "think") {
state.slashMenuOpen = false;
resetSlashMenuState(state);
}
const slashMenuVisible = props.connected && canCompose && isSlashMenuVisible(state);
const skillMenuVisible = props.connected && canCompose && isSkillMenuVisible(state);
if (!skillMenuVisible && state.skillMenuOpen && !state.skillCommandRefreshPending) {