feat(chat): add session reasoning effort picker with Low/Medium/High/Extra High levels

This commit is contained in:
paisley
2026-08-04 17:10:03 +08:00
parent bbbf6d5bb8
commit 0072082e31
26 changed files with 887 additions and 84 deletions
+2 -1
View File
@@ -106,6 +106,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
コンポーザーのモデルメニューには、現在のモデルについて OpenClaw が公開する推論強度も表示されます。選択した強度は現在のセッションに適用され、「継承」を選ぶとランタイムが解決した既定値に従います。
Chat の右パネルにあるワークスペースとプレビューの各タブでは、`.docx``.pptx` ファイルを読み取り専用でプレビューできます。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc``.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。
@@ -135,7 +136,7 @@ Skills ページでは OpenClaw の複数ソース(管理ディレクトリ、
開発者モードでは、専用の Image Generation ページで、独立した OpenAI 互換の画像生成エンドポイント(Base URL、API キー、`gpt-image-2` などのモデル名)を設定でき、画像生成だけ専用の `/v1/images/generations` サービスを使い、チャットは通常の OpenAI Provider のまま継続できます。
OpenAI-compatible ゲートウェイを **Custom プロバイダー** で使う場合、**設定 → AI Providers → Provider 編集** でカスタム `User-Agent` を設定でき、互換性が必要なエンドポイントで有効です。
プロバイダーの編集や切り替え時、ClawX は `input: ["text", "image"]` など既存のモデル単位の能力メタデータを保持します。新しく選択した Custom プロバイダーのモデルには OpenClaw onboarding と同等の画像入力推論を適用し、不明なモデルはテキスト専用として扱います。
Custom プロバイダーのモデル行には明示的な `contextWindow` も書き込まれ(モデルファミリーから推定、例:`gpt-5.x` → 272k)、旧バージョンで保存された行は起動時に自動補完されます。これにより OpenClaw は長いセッションを "Context overflow" エラーになる前に圧縮できます。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが自分で設定したモデル行や圧縮設定が変更されることはありません(`reserveTokensFloor` が未設定の場合のみ補完されることがあります
Custom プロバイダーのモデル行には明示的な `contextWindow` も書き込まれ(モデルファミリーから推定、例:`gpt-5.x` → 272k)、旧バージョンで保存された行は起動時に自動補完されます。これにより OpenClaw は長いセッションを "Context overflow" エラーになる前に圧縮できます。GPT-5.5+ や GLM-5.2+ など、認識済みのホスト型 OpenAI 互換推論モデルでは、能力メタデータがない場合に Low / Medium / High / Extra High の推論プロファイルも補完されます。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが設定したフィールドは上書きされず、欠けている推論可能なフィールドだけが補完されます。
Z.AICN / Global)は OpenClaw 組み込みの `zai` プロバイダー(`ZAI_API_KEY`)に対応し、既定モデルは `glm-5.2` です。Code Plan プリセットで Coding Plan エンドポイント(`…/api/coding/paas/v4`)へ切り替え、通常 API`…/api/paas/v4`)も利用できます。CN と Global は同じ OpenClaw ランタイムキーを共有するため同時追加できません。
互換ゲートウェイで `/models` が認証以外の理由で使えない場合、ClawX は API キー検証時に設定済みモデルを使った軽量な `/chat/completions` または `/responses` プローブへ自動フォールバックします。
+2 -1
View File
@@ -106,6 +106,7 @@ Skills you insert from the composer appear as `/skill-name` chips; click a chip
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name.
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
The composer model menu also shows the reasoning-effort levels advertised by OpenClaw for the current model. A selected level applies to the current session, while the inherited option follows the runtime-resolved default.
The Workspace and Preview tabs in Chat's right panel provide read-only previews for `.docx` and `.pptx` files. The Preview header can expand the selected file to the full ClawX viewport; use the same control or Escape to return to the panel. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline.
@@ -135,7 +136,7 @@ Connect to multiple AI providers (OpenAI, Anthropic, Z.AI / GLM, and more) with
In developer mode, the dedicated Image Generation page supports an independent OpenAI-compatible image-generation endpoint (Base URL, API key, and model name such as `gpt-image-2`) so image generation can use a dedicated `/v1/images/generations` service while chat continues using the normal OpenAI provider.
For **Custom** providers used with OpenAI-compatible gateways, you can set a custom `User-Agent` in **Settings → AI Providers → Edit Provider** for compatibility-sensitive endpoints.
When you edit or switch providers, ClawX preserves existing per-model capability metadata such as `input: ["text", "image"]`. Newly selected Custom-provider models use OpenClaw onboarding-compatible image-input inference, with unknown models defaulting to text-only.
Custom-provider model rows also receive an explicit `contextWindow` (inferred from the model family, e.g. `gpt-5.x` → 272k), and rows saved by older versions are backfilled on startup, so OpenClaw can compact long sessions before they fail with "Context overflow" errors. When you have no compaction config, ClawX seeds `agents.defaults.compaction.mode = "safeguard"` and `reserveTokensFloor = 50000`; rows or configs you authored yourself are never modified (except a missing `reserveTokensFloor` may be backfilled).
Custom-provider model rows also receive an explicit `contextWindow` (inferred from the model family, e.g. `gpt-5.x` → 272k), and rows saved by older versions are backfilled on startup, so OpenClaw can compact long sessions before they fail with "Context overflow" errors. Recognized hosted OpenAI-compatible reasoning models such as GPT-5.5+ and GLM-5.2+ also receive the Low / Medium / High / Extra High effort profile when that metadata is missing. When you have no compaction config, ClawX seeds `agents.defaults.compaction.mode = "safeguard"` and `reserveTokensFloor = 50000`; rows or configs you authored yourself are never modified (except missing inferred fields may be backfilled).
Z.AI (CN / Global) maps to OpenClaw's built-in `zai` provider (`ZAI_API_KEY`). Default model is `glm-5.2`. Use the Code Plan preset for Coding Plan endpoints (`…/api/coding/paas/v4`) or the normal API endpoints (`…/api/paas/v4`); CN and Global are mutually exclusive because they share one OpenClaw runtime key.
When a compatible gateway rejects `/models` for non-auth reasons, ClawX automatically falls back to a lightweight `/chat/completions` or `/responses` probe using the configured model during API key validation.
+2 -1
View File
@@ -107,6 +107,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
输入框的模型菜单还会显示 OpenClaw 针对当前模型提供的推理强度选项。所选强度仅对当前会话持续生效;选择“继承”则跟随运行时解析出的默认值。
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 `.docx``.pptx` 文件。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc``.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。
@@ -136,7 +137,7 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor
在开发者模式下,独立的“图像生成”页面支持配置 OpenAI 兼容生图端点(Base URL、API Key 和模型名,例如 `gpt-image-2`),生图请求会走专用的 `/v1/images/generations` 服务,聊天仍继续使用正常的 OpenAI Provider。
如果你通过 **自定义(CustomProvider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。
编辑或切换 Provider 时,ClawX 会保留已有的模型级能力元数据,例如 `input: ["text", "image"]`。新选择的自定义 Provider 模型会使用与 OpenClaw onboarding 一致的图片输入能力推断;未知模型默认按纯文本模型处理。
自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000`;你手动配置过的模型行或压缩配置永远不会被修改(仅可能回填缺失的 `reserveTokensFloor`
自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。对于 GPT-5.5+、GLM-5.2+ 等已识别的托管 OpenAI 兼容推理模型,缺少能力元数据时还会补齐低 / 中 / 高 / 超高推理档位。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"``reserveTokensFloor = 50000`;你手动配置过的字段不会被覆盖,仅会回填缺失的推断字段
Z.AI(国内站 / 国际站)会映射到 OpenClaw 内置的 `zai` 供应商(`ZAI_API_KEY`),默认模型为 `glm-5.2`。可通过 Code Plan 预设切换到编码套餐端点(`…/api/coding/paas/v4`),或使用普通 API 端点(`…/api/paas/v4`);国内站与国际站互斥,因为它们共享同一个 OpenClaw 运行时 key。
如果兼容网关的 `/models` 因非鉴权原因不可用,ClawX 会在校验 API Key 时使用已配置的模型,自动降级为轻量的 `/chat/completions``/responses` 探测。
@@ -1,5 +1,12 @@
export type ModelInputModality = 'text' | 'image';
export type CustomModelReasoningCapabilities = {
reasoning: true;
compat: {
supportedReasoningEfforts: string[];
};
};
type ContextWindowRule = {
/** Human-readable family label; kept so the table reads as documentation. */
label: string;
@@ -188,3 +195,40 @@ export function inferCustomModelInputModalities(modelId: string): ModelInputModa
const supportsImageInput = VISION_MODEL_PATTERNS.some((pattern) => matchesModelId(pattern, modelId));
return supportsImageInput ? ['text', 'image'] : ['text'];
}
const STANDARD_REASONING_EFFORTS = ['low', 'medium', 'high', 'xhigh'];
const OPENAI_COMPATIBLE_REASONING_PROTOCOLS = new Set([
'openai-completions',
'openai-responses',
]);
const STANDARD_REASONING_MODEL_PATTERNS = [
/\bgpt-5\.[2-9]\b/,
/\bglm-5\.[2-9]\b/,
];
/**
* Adds the standard effort ladder only for hosted OpenAI-compatible model
* families whose public APIs accept reasoning_effort. Unknown and local models
* remain runtime-owned instead of receiving an optimistic capability claim.
*/
export function inferCustomModelReasoningCapabilities(
modelId: string,
context: ModelCapabilityContext = {},
): CustomModelReasoningCapabilities | undefined {
if (isLocalProviderKey(context.providerKey)) return undefined;
if (
context.apiProtocol
&& !OPENAI_COMPATIBLE_REASONING_PROTOCOLS.has(context.apiProtocol.trim().toLowerCase())
) {
return undefined;
}
if (!STANDARD_REASONING_MODEL_PATTERNS.some((pattern) => matchesModelId(pattern, modelId))) {
return undefined;
}
return {
reasoning: true,
compat: {
supportedReasoningEfforts: [...STANDARD_REASONING_EFFORTS],
},
};
}
+68 -18
View File
@@ -40,7 +40,11 @@ import {
assertValidApiProtocol,
normalizeOpenClawApiProtocol,
} from '../shared/providers/types';
import { inferCustomModelContextWindow, inferCustomModelInputModalities } from '../shared/providers/model-capabilities';
import {
inferCustomModelContextWindow,
inferCustomModelInputModalities,
inferCustomModelReasoningCapabilities,
} from '../shared/providers/model-capabilities';
import {
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
@@ -906,35 +910,62 @@ function backfillCompactionReserveTokensFloor(config: Record<string, unknown>):
}
/**
* Self-heal helper: walk `models.providers.custom-*` entries and fill in an
* inferred `contextWindow` on model rows that have neither `contextWindow`
* nor `contextTokens`. Rows written by older ClawX versions only carried
* `{ id, name, input }`, which disables OpenClaw's preemptive compaction and
* context-window guard for custom providers.
* Add inferred reasoning metadata without replacing user/runtime-owned fields.
*/
function applyInferredCustomModelReasoningCapabilities(
row: Record<string, unknown>,
modelId: string,
context: { providerKey: string; apiProtocol?: string },
): boolean {
const inferred = inferCustomModelReasoningCapabilities(modelId, context);
if (!inferred || (row.reasoning !== undefined && row.reasoning !== true)) return false;
let changed = false;
if (row.reasoning === undefined) {
row.reasoning = true;
changed = true;
}
const compat = isPlainRecord(row.compat) ? { ...row.compat } : {};
if (compat.supportedReasoningEfforts === undefined) {
compat.supportedReasoningEfforts = [...inferred.compat.supportedReasoningEfforts];
row.compat = compat;
changed = true;
}
return changed;
}
/**
* Self-heal helper: walk `models.providers.custom-*` entries and fill inferred
* context-window and reasoning metadata that older ClawX versions omitted.
*
* Deliberately scoped to `custom-` keys: registry providers own their
* metadata, and small local models (ollama) must not inherit a large window.
*/
function backfillCustomProviderModelContextWindows(config: Record<string, unknown>): string[] {
function backfillCustomProviderModelCapabilities(config: Record<string, unknown>): string[] {
const models = (config.models || {}) as Record<string, unknown>;
const providers = (models.providers || {}) as Record<string, unknown>;
const backfilled: string[] = [];
const backfilled = new Set<string>();
for (const [providerKey, entry] of Object.entries(providers)) {
if (!providerKey.startsWith('custom-') || !isPlainRecord(entry)) continue;
const rows = Array.isArray(entry.models) ? entry.models : [];
for (const row of rows) {
if (!isPlainRecord(row) || typeof row.id !== 'string' || !row.id) continue;
if (typeof row.contextWindow === 'number' || typeof row.contextTokens === 'number') continue;
row.contextWindow = inferCustomModelContextWindow(row.id, {
const context = {
providerKey,
apiProtocol: typeof entry.api === 'string' ? entry.api : undefined,
});
backfilled.push(`${providerKey}/${row.id}`);
};
if (typeof row.contextWindow !== 'number' && typeof row.contextTokens !== 'number') {
row.contextWindow = inferCustomModelContextWindow(row.id, context);
backfilled.add(`${providerKey}/${row.id}`);
}
if (applyInferredCustomModelReasoningCapabilities(row, row.id, context)) {
backfilled.add(`${providerKey}/${row.id}`);
}
}
}
return backfilled;
return [...backfilled];
}
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
@@ -1911,6 +1942,15 @@ function upsertOpenClawProviderEntry(
const existingModels = options.mergeExistingModels && Array.isArray(existingProvider.models)
? (existingProvider.models as Array<Record<string, unknown>>)
: [];
if (options.inferRuntimeModelInputs && provider.startsWith('custom-')) {
for (const model of existingModels) {
if (typeof model.id !== 'string' || !model.id) continue;
applyInferredCustomModelReasoningCapabilities(model, model.id, {
providerKey: provider,
apiProtocol: options.api,
});
}
}
const registryModels = options.includeRegistryModels
? ((getProviderConfig(provider)?.models ?? []).map((m) => ({ ...m })) as Array<Record<string, unknown>>)
: [];
@@ -1926,6 +1966,10 @@ function upsertOpenClawProviderEntry(
providerKey: provider,
apiProtocol: options.api,
}),
...inferCustomModelReasoningCapabilities(id, {
providerKey: provider,
apiProtocol: options.api,
}),
}
: {}),
}));
@@ -2750,11 +2794,11 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
);
}
// ── Custom provider contextWindow backfill ──
const backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
if (backfilledContextWindows.length > 0) {
// ── Custom provider model-capability backfill ──
const backfilledModelCapabilities = backfillCustomProviderModelCapabilities(config);
if (backfilledModelCapabilities.length > 0) {
modified = true;
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
console.log(`[batch-sync] Backfilled custom provider model capabilities: ${backfilledModelCapabilities.join(', ')}`);
}
if (modified) {
@@ -2819,7 +2863,7 @@ async function updateModelsJsonProviderEntriesForAgents(
const prev = existingModels.find((e) => e.id === m.id);
const base = prev ? { ...prev, id: m.id, name: m.name } : { ...m };
// Custom-provider rows need an explicit contextWindow so the embedded
// runner can budget compaction (see backfillCustomProviderModelContextWindows).
// runner can budget compaction (see backfillCustomProviderModelCapabilities).
if (
providerType.startsWith('custom-')
&& typeof base.contextWindow !== 'number'
@@ -2830,6 +2874,12 @@ async function updateModelsJsonProviderEntriesForAgents(
apiProtocol: entry.api,
});
}
if (providerType.startsWith('custom-')) {
applyInferredCustomModelReasoningCapabilities(base, m.id, {
providerKey: providerType,
apiProtocol: entry.api,
});
}
return {
...base,
cost: normalizePiAiModelCost((base as { cost?: unknown }).cost),
@@ -10,6 +10,8 @@ appliesTo:
Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while ordinary live prompt updates continue through host events. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history.
Current-session reasoning effort is Gateway session metadata, not ACP timeline state. The Chat picker must use the selected row's `thinkingLevels` as the supported option set, `thinkingDefault` as the inherited value, and `thinkingLevel` as the explicit override. Updates go through `sessions.patch`; `null` clears the override while `off` remains an explicit value. Renderer must not derive support from provider or model-name patterns, and sending must wait until an in-flight picker patch settles.
ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history.
Historical transcript reads are limited to the newest `1000` message records. A successful live prompt reads content immediately and retries exactly once after `1500 ms`. General attachment and timing alignment treat history as a suffix and match the binary-free OpenClaw prompt-text projection of structured ACP user blocks by duplicate occurrence from the tail; they must not parse or globally remove user-authored resource marker text. Attachment-only empty projections remain eligible, and live content alignment also requires the current optimistic user identity. Every asynchronous result must retain the same active session, generation, supplement operation and attempt, and live turn where applicable. Unmatched, ambiguous, superseded, or stale work cannot mutate the timeline or timing annotations.
@@ -23,3 +23,10 @@ existing rows missing both `contextWindow` and `contextTokens` may be
backfilled with that default. Rows that already declare either field are
user-owned and must never be modified, and non-`custom-` provider entries are
never backfilled.
Hosted OpenAI-compatible custom rows for a recognized reasoning-effort model
family may also receive deterministic `reasoning` and
`compat.supportedReasoningEfforts` defaults. Inference may fill only missing
fields: an explicit `reasoning: false` or an existing compat effort list is
user/runtime-owned and must remain unchanged. Local and non-OpenAI-compatible
transports must not inherit these hosted capability defaults.
@@ -89,3 +89,5 @@ Scheduled-task history is Main-owned backend data. Current OpenClaw versions mus
The local HTML Preview privileged bridge is also Main-owned: Renderer may load a validated local HTML file or open that current file externally through the typed Host API. The guest is an implementation detail of the existing `preview` tab; there is no `web-browser` artifact tab or general address navigation. The durable guest contract is `harness/reference/web-browser.md`.
Gateway session-catalog subscription, normalization, ordered list/event replay, attention transitions, and reconnect recovery are documented in `harness/reference/sidebar-session-attention.md`.
Gateway session rows are also authoritative for Chat reasoning-effort controls. Renderer may project `thinkingLevel`, `thinkingLevels`, and `thinkingDefault` into the session catalog and may update the explicit current-session override only through the Main-owned `sessions.patch` RPC boundary. Renderer must not infer provider-specific thinking support from model names; deterministic custom-provider capability inference belongs to Main's model-sync layer and must be written into OpenClaw metadata before Gateway validation.
@@ -0,0 +1,76 @@
---
id: chat-session-reasoning-effort
title: Add a session-scoped reasoning effort picker to Chat
scenario: gateway-backend-communication
taskType: runtime-bridge
intent: Let users select the current OpenClaw session thinking level from the existing Chat model control without duplicating provider capability rules in ClawX.
touchedAreas:
- harness/specs/tasks/chat-session-reasoning-effort.md
- harness/specs/scenarios/gateway-backend-communication.md
- harness/specs/rules/acp-chat-state-and-history.md
- harness/specs/rules/provider-model-metadata-preservation.md
- electron/shared/providers/model-capabilities.ts
- electron/utils/openclaw-auth.ts
- shared/chat/types.ts
- src/stores/chat.ts
- src/stores/chat/session-actions.ts
- src/stores/chat/session-catalog.ts
- src/pages/Chat/ChatInput.tsx
- shared/i18n/locales/**/chat.json
- tests/unit/session-catalog.test.ts
- tests/unit/provider-model-capabilities.test.ts
- tests/unit/openclaw-auth.test.ts
- tests/unit/chat-input.test.tsx
- tests/unit/chat-store-session-label-fetch.test.ts
- tests/e2e/chat-model-picker.spec.ts
- README.md
- README.zh-CN.md
- README.ja-JP.md
expectedUserBehavior:
- The Chat model button shows the effective reasoning effort for the current session.
- The model menu offers only the thinking levels advertised by OpenClaw for the resolved model.
- Selecting a level persists an explicit current-session override through Gateway sessions.patch.
- Selecting the inherited option clears the override and displays the Gateway-resolved default.
- A message cannot be sent while an effort change is still being applied.
requiredProfiles:
- fast
- comms
- e2e
requiredRules:
- renderer-main-boundary
- backend-communication-boundary
- acp-chat-state-and-history
- provider-model-metadata-preservation
- ui-i18n-design-tokens
- comms-regression
- docs-sync
requiredTests:
- tests/unit/session-catalog.test.ts
- tests/unit/provider-model-capabilities.test.ts
- tests/unit/openclaw-auth.test.ts
- tests/unit/chat-input.test.tsx
- tests/unit/chat-store-session-label-fetch.test.ts
- tests/e2e/chat-model-picker.spec.ts
acceptance:
- Renderer uses the typed host-api Gateway RPC boundary and never opens its own Gateway transport.
- thinkingLevels, thinkingDefault, and thinkingLevel remain Gateway-owned session metadata.
- Recognized hosted custom-provider reasoning models receive missing OpenClaw capability metadata without replacing explicit user/runtime values.
- Explicit off is distinct from a cleared override.
- Failed patches restore the prior session state and leave the message available to send.
- New labels are localized in English, Chinese, Japanese, and Russian.
- Focused tests, harness validation, communication replay, and communication compare pass.
docs:
required: true
---
## Scope
- Project Gateway session thinking metadata into the Chat session catalog.
- Add a combined model and reasoning-effort picker to the composer.
- Persist current-session overrides with `sessions.patch`.
## Out Of Scope
- Adding per-message or per-agent thinking defaults.
- Maintaining a model/provider capability table in ClawX.
- Changing ACP prompt payloads or OpenClaw reasoning semantics.
+12
View File
@@ -83,6 +83,11 @@ export interface ContentBlock {
content?: unknown;
}
export interface ThinkingLevelOption {
id: string;
label: string;
}
/** Session from sessions.list */
export interface ChatSession {
key: string;
@@ -92,7 +97,12 @@ export interface ChatSession {
displayName?: string;
derivedTitle?: string;
lastMessagePreview?: string;
/** Explicit session override. Undefined means inherit the Gateway-resolved default. */
thinkingLevel?: string;
/** Model/runtime-specific options advertised by the Gateway. */
thinkingLevels?: ThinkingLevelOption[];
/** Effective inherited value when no explicit session override is present. */
thinkingDefault?: string;
model?: string;
updatedAt?: number;
status?: string;
@@ -182,6 +192,7 @@ export interface ChatState {
// Thinking
thinkingLevel: string | null;
thinkingLevelUpdatingSessionKey: string | null;
// Actions
loadSessions: (options?: LoadSessionsOptions) => Promise<void>;
@@ -193,6 +204,7 @@ export interface ChatState {
deleteSession: (key: string) => Promise<void>;
deleteSessions: (keys: string[]) => Promise<DeleteSessionsResult>;
renameSession: (key: string, label: string) => Promise<void>;
updateSessionThinkingLevel: (key: string, level: string | null) => Promise<void>;
cleanupEmptySession: () => void;
loadHistory: (quiet?: boolean) => Promise<void>;
loadMoreHistory: () => Promise<void>;
+6
View File
@@ -263,11 +263,17 @@
"skillEmpty": "No matching skills found",
"pickAgent": "Choose agent",
"pickModel": "Choose model",
"modelControlTitle": "Model and reasoning effort",
"modelSectionTitle": "Model",
"clearTarget": "Clear target agent",
"targetChip": "@{{agent}}",
"agentPickerTitle": "Route the next message to another agent",
"modelPickerTitle": "Switch model for this chat",
"modelSwitchFailed": "Failed to switch model: {{error}}",
"reasoningEffortTitle": "Reasoning effort",
"reasoningEffortDefault": "Default",
"reasoningEffortInherited": "Inherited: {{level}}",
"reasoningEffortUpdateFailed": "Failed to update reasoning effort: {{error}}",
"gatewayDisconnectedPlaceholder": "Gateway not connected...",
"send": "Send",
"stop": "Stop",
+6
View File
@@ -263,8 +263,14 @@
"skillEmpty": "一致する Skill がありません",
"pickAgent": "Agent を選択",
"pickModel": "モデルを選択",
"modelControlTitle": "モデルと推論強度",
"modelSectionTitle": "モデル",
"modelPickerTitle": "モデルを切り替え",
"modelSwitchFailed": "モデルの切り替えに失敗しました: {{error}}",
"reasoningEffortTitle": "推論強度",
"reasoningEffortDefault": "デフォルト",
"reasoningEffortInherited": "継承: {{level}}",
"reasoningEffortUpdateFailed": "推論強度の更新に失敗しました: {{error}}",
"clearTarget": "送信先 Agent をクリア",
"targetChip": "@{{agent}}",
"agentPickerTitle": "次のメッセージを別の Agent に直接送信します",
+6
View File
@@ -263,8 +263,14 @@
"skillEmpty": "Подходящие Skill не найдены",
"pickAgent": "Выбрать агента",
"pickModel": "Выбрать модель",
"modelControlTitle": "Модель и глубина рассуждений",
"modelSectionTitle": "Модель",
"modelPickerTitle": "Переключить модель",
"modelSwitchFailed": "Не удалось переключить модель: {{error}}",
"reasoningEffortTitle": "Глубина рассуждений",
"reasoningEffortDefault": "По умолчанию",
"reasoningEffortInherited": "Унаследовано: {{level}}",
"reasoningEffortUpdateFailed": "Не удалось изменить глубину рассуждений: {{error}}",
"clearTarget": "Очистить целевого агента",
"targetChip": "@{{agent}}",
"agentPickerTitle": "Направить следующее сообщение другому агенту",
+6
View File
@@ -263,8 +263,14 @@
"skillEmpty": "未找到匹配的技能",
"pickAgent": "选择 Agent",
"pickModel": "选择模型",
"modelControlTitle": "模型与推理强度",
"modelSectionTitle": "模型",
"modelPickerTitle": "切换当前 Agent 模型",
"modelSwitchFailed": "模型切换失败:{{error}}",
"reasoningEffortTitle": "推理强度",
"reasoningEffortDefault": "默认",
"reasoningEffortInherited": "继承:{{level}}",
"reasoningEffortUpdateFailed": "推理强度更新失败:{{error}}",
"clearTarget": "清除目标 Agent",
"targetChip": "@{{agent}}",
"agentPickerTitle": "将下一条消息直接发送给其他 Agent",
+130 -26
View File
@@ -241,6 +241,10 @@ export function ChatInput({
const providerError = useProviderStore((s) => s.error);
const refreshProviderSnapshot = useProviderStore((s) => s.refreshProviderSnapshot);
const currentAgentId = useChatStore((s) => s.currentAgentId);
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
const sessions = useChatStore((s) => s.sessions);
const thinkingLevelUpdatingSessionKey = useChatStore((s) => s.thinkingLevelUpdatingSessionKey);
const updateSessionThinkingLevel = useChatStore((s) => s.updateSessionThinkingLevel);
const currentAgent = useMemo(
() => (agents ?? []).find((agent) => agent.id === currentAgentId) ?? null,
[agents, currentAgentId],
@@ -267,6 +271,28 @@ export function ChatInput({
const matchedOption = modelOptions.find((option) => option.modelRef === effectiveModelRef);
return matchedOption?.label || formatModelRefLabel(effectiveModelRef);
}, [effectiveModelRef, modelOptions]);
const currentSession = useMemo(
() => sessions.find((session) => session.key === currentSessionKey),
[currentSessionKey, sessions],
);
const thinkingOptions = useMemo(
() => currentSession?.thinkingLevels ?? [],
[currentSession?.thinkingLevels],
);
const effectiveThinkingLevel = currentSession?.thinkingLevel ?? currentSession?.thinkingDefault;
const currentThinkingLabel = useMemo(() => {
if (!effectiveThinkingLevel) return '';
return thinkingOptions.find((option) => option.id === effectiveThinkingLevel)?.label
?? effectiveThinkingLevel;
}, [effectiveThinkingLevel, thinkingOptions]);
const inheritedThinkingLabel = currentSession?.thinkingDefault
? thinkingOptions.find((option) => option.id === currentSession.thinkingDefault)?.label
?? currentSession.thinkingDefault
: t('composer.reasoningEffortDefault');
const currentModelControlLabel = currentThinkingLabel
? `${currentModelLabel} · ${currentThinkingLabel}`
: currentModelLabel;
const switchingThinkingLevel = thinkingLevelUpdatingSessionKey === currentSessionKey;
const mentionableAgents = useMemo(
() => (agents ?? []).filter((agent) => agent.id !== currentAgentId),
[agents, currentAgentId],
@@ -285,7 +311,7 @@ export function ChatInput({
);
}, [quickSkills, skillQuery]);
const showAgentPicker = mentionableAgents.length > 0;
const showModelPicker = modelOptions.length > 1;
const showModelPicker = modelOptions.length > 1 || thinkingOptions.length > 0;
const chatComposerStatusComponents = rendererExtensionRegistry.getChatComposerStatusComponents();
const isGatewayUsable = gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false;
const inputDisabled = disabled;
@@ -523,6 +549,29 @@ export function ChatInput({
}
}, [currentAgent, defaultModelRef, effectiveModelRef, switchingModelRef, t, updateAgentModel]);
const handleSelectThinkingLevel = useCallback(async (level: string | null) => {
if (switchingThinkingLevel || level === (currentSession?.thinkingLevel ?? null)) {
setModelPickerOpen(false);
textareaRef.current?.focus();
return;
}
setModelPickerOpen(false);
try {
await updateSessionThinkingLevel(currentSessionKey, level);
} catch (error) {
toast.error(t('composer.reasoningEffortUpdateFailed', { error: String(error) }));
} finally {
textareaRef.current?.focus();
}
}, [
currentSession?.thinkingLevel,
currentSessionKey,
switchingThinkingLevel,
t,
updateSessionThinkingLevel,
]);
const handleWorkspaceButtonClick = useCallback(() => {
if (workspaceSelectorDisabled) return;
setPickerOpen(false);
@@ -688,6 +737,7 @@ export function ChatInput({
&& allReady
&& !inputDisabled
&& !sending
&& !switchingThinkingLevel
&& !imageGenerating;
const canStop = sending && !inputDisabled && !!onStop;
@@ -1136,8 +1186,8 @@ export function ChatInput({
type="button"
data-testid="chat-model-picker-button"
className={cn(
'inline-flex h-8 max-w-[220px] items-center gap-1 rounded-lg px-1.5 text-meta font-medium text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:opacity-50',
(modelPickerOpen || switchingModelRef) && 'text-foreground',
'inline-flex h-8 max-w-[280px] items-center gap-1 rounded-lg px-1.5 text-meta font-medium text-muted-foreground transition-colors hover:bg-transparent hover:text-foreground focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:opacity-50',
(modelPickerOpen || switchingModelRef || switchingThinkingLevel) && 'text-foreground',
)}
onClick={() => {
setPickerOpen(false);
@@ -1145,13 +1195,13 @@ export function ChatInput({
setWorkspaceMenuOpen(false);
setModelPickerOpen((open) => !open);
}}
disabled={inputDisabled || sending || !currentAgent || !!switchingModelRef}
title={t('composer.pickModel')}
disabled={inputDisabled || sending || !currentAgent || !!switchingModelRef || switchingThinkingLevel}
title={t('composer.modelControlTitle')}
>
{switchingModelRef ? (
{(switchingModelRef || switchingThinkingLevel) ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
) : null}
<span className="truncate">{currentModelLabel}</span>
<span className="truncate">{currentModelControlLabel}</span>
<ChevronDown className={cn('h-3.5 w-3.5 shrink-0 transition-transform', modelPickerOpen && 'rotate-180')} />
</button>
{modelPickerOpen && (
@@ -1160,26 +1210,80 @@ export function ChatInput({
data-testid="chat-model-picker-menu"
>
<div className="px-3 py-2 text-tiny font-medium text-muted-foreground/80">
{t('composer.modelPickerTitle')}
{t('composer.modelControlTitle')}
</div>
<div className="max-h-64 overflow-y-auto">
{modelOptions.map((option) => (
<button
key={option.modelRef}
type="button"
onClick={() => void handleSelectModel(option.modelRef)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors',
option.modelRef === effectiveModelRef ? 'bg-primary/10 text-foreground' : 'hover:bg-black/5 dark:hover:bg-white/5'
)}
data-testid={`chat-model-picker-option-${option.label}`}
>
<span className="truncate">{option.label}</span>
{option.modelRef === effectiveModelRef && (
<span className="h-1.5 w-1.5 rounded-full bg-primary" />
)}
</button>
))}
<div className="max-h-72 overflow-y-auto">
{modelOptions.length > 1 && (
<>
<div className="px-3 pb-1 pt-1 text-tiny font-medium text-muted-foreground/70">
{t('composer.modelSectionTitle')}
</div>
{modelOptions.map((option) => (
<button
key={option.modelRef}
type="button"
onClick={() => void handleSelectModel(option.modelRef)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors',
option.modelRef === effectiveModelRef
? 'bg-black/5 text-foreground dark:bg-white/10'
: 'hover:bg-black/5 dark:hover:bg-white/5',
)}
data-testid={`chat-model-picker-option-${option.label}`}
>
<span className="truncate">{option.label}</span>
{option.modelRef === effectiveModelRef && (
<Check className="h-3.5 w-3.5 shrink-0" />
)}
</button>
))}
</>
)}
{thinkingOptions.length > 0 && (
<>
{modelOptions.length > 1 && <div className="mx-2 my-1.5 h-px bg-border" />}
<div className="px-3 pb-1 pt-1 text-tiny font-medium text-muted-foreground/70">
{t('composer.reasoningEffortTitle')}
</div>
<button
type="button"
onClick={() => void handleSelectThinkingLevel(null)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors',
currentSession?.thinkingLevel === undefined
? 'bg-black/5 text-foreground dark:bg-white/10'
: 'hover:bg-black/5 dark:hover:bg-white/5',
)}
data-testid="chat-reasoning-effort-option-inherited"
>
<span className="truncate">
{t('composer.reasoningEffortInherited', { level: inheritedThinkingLabel })}
</span>
{currentSession?.thinkingLevel === undefined && (
<Check className="h-3.5 w-3.5 shrink-0" />
)}
</button>
{thinkingOptions.map((option) => (
<button
key={option.id}
type="button"
onClick={() => void handleSelectThinkingLevel(option.id)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors',
currentSession?.thinkingLevel === option.id
? 'bg-black/5 text-foreground dark:bg-white/10'
: 'hover:bg-black/5 dark:hover:bg-white/5',
)}
data-testid={`chat-reasoning-effort-option-${option.id}`}
>
<span className="truncate">{option.label}</span>
{currentSession?.thinkingLevel === option.id && (
<Check className="h-3.5 w-3.5 shrink-0" />
)}
</button>
))}
</>
)}
</div>
</div>
)}
+68
View File
@@ -29,6 +29,7 @@ import { fetchCronSessionHistory } from '@/lib/cron-session-history';
import { pickStartupSessionFallback } from './chat/session-selection';
import {
applyGatewaySessionsChanged,
normalizeGatewaySessionPatch,
normalizeGatewaySessionRow,
type GatewaySessionsChangedPayload,
} from './chat/session-catalog';
@@ -2872,6 +2873,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
sessionLastActivity: {},
thinkingLevel: null,
thinkingLevelUpdatingSessionKey: null,
// ── Load sessions via sessions.list ──
@@ -3612,6 +3614,72 @@ export const useChatStore = create<ChatState>((set, get) => ({
}));
},
updateSessionThinkingLevel: async (key: string, level: string | null) => {
const state = get();
if (state.thinkingLevelUpdatingSessionKey) {
throw new Error('A reasoning effort update is already in progress');
}
const previousSession = state.sessions.find((session) => session.key === key);
set((current) => ({
thinkingLevelUpdatingSessionKey: key,
sessions: current.sessions.map((session) => {
if (session.key !== key) return session;
if (level !== null) return { ...session, thinkingLevel: level };
const { thinkingLevel: _thinkingLevel, ...inheritedSession } = session;
return inheritedSession;
}),
}));
try {
const result = await hostApi.gateway.rpc<{
resolved?: {
thinkingLevel?: unknown;
thinkingLevels?: unknown;
};
}>('sessions.patch', { key, thinkingLevel: level });
const resolved = result?.resolved;
const resolvedPatch = normalizeGatewaySessionPatch({
key,
...(Array.isArray(resolved?.thinkingLevels)
? { thinkingLevels: resolved.thinkingLevels }
: {}),
});
const inheritedLevel = level === null && typeof resolved?.thinkingLevel === 'string'
? resolved.thinkingLevel.trim()
: '';
set((current) => ({
sessions: current.sessions.map((session) => {
if (session.key !== key) return session;
const next = { ...session };
if (level === null) delete next.thinkingLevel;
else next.thinkingLevel = level;
if (resolvedPatch.values.thinkingLevels) {
next.thinkingLevels = resolvedPatch.values.thinkingLevels;
}
if (inheritedLevel) next.thinkingDefault = inheritedLevel;
return next;
}),
}));
await get().loadSessions({ force: true });
} catch (error) {
set((current) => ({
sessions: previousSession
? current.sessions.map((session) => session.key === key ? previousSession : session)
: current.sessions,
}));
throw error;
} finally {
set((current) => ({
thinkingLevelUpdatingSessionKey:
current.thinkingLevelUpdatingSessionKey === key
? null
: current.thinkingLevelUpdatingSessionKey,
}));
}
},
// ── Cleanup empty session on navigate away ──
cleanupEmptySession: () => {
+3
View File
@@ -24,6 +24,7 @@ export const initialChatState: Pick<
| 'sessionLabels'
| 'sessionLastActivity'
| 'thinkingLevel'
| 'thinkingLevelUpdatingSessionKey'
> = {
messages: [],
loading: false,
@@ -47,6 +48,7 @@ export const initialChatState: Pick<
sessionLastActivity: {},
thinkingLevel: null,
thinkingLevelUpdatingSessionKey: null,
};
export function createChatActions(
@@ -61,6 +63,7 @@ export function createChatActions(
| 'acknowledgeAcpSessionCreated'
| 'deleteSession'
| 'renameSession'
| 'updateSessionThinkingLevel'
| 'cleanupEmptySession'
| 'loadHistory'
| 'sendMessage'
+71 -31
View File
@@ -5,6 +5,7 @@ import {
shouldIncludeSessionInSidebarList,
} from './session-key-utils';
import { pickStartupSessionFallback } from './session-selection';
import { normalizeGatewaySessionPatch, normalizeGatewaySessionRow } from './session-catalog';
import { clearPendingOptimisticUserMessages, getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
@@ -56,23 +57,6 @@ function applySessionBackendLabels(set: ChatSet, sessions: ChatSession[]): void
}));
}
function parseSessionUpdatedAtMs(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return toMs(value);
}
if (typeof value === 'string' && value.trim()) {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}
function parseSessionStatus(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined;
}
function sessionIndicatesIdle(session: ChatSession | undefined): boolean {
if (!session) return false;
if (session.hasActiveRun === false) return true;
@@ -118,7 +102,7 @@ function reconcileCurrentSessionIdleFromBackend(set: ChatSet, get: ChatGet, sess
export function createSessionActions(
set: ChatSet,
get: ChatGet,
): Pick<SessionHistoryActions, 'loadSessions' | 'switchSession' | 'selectAcpSession' | 'newSession' | 'acknowledgeAcpSessionCreated' | 'deleteSession' | 'renameSession' | 'cleanupEmptySession'> {
): Pick<SessionHistoryActions, 'loadSessions' | 'switchSession' | 'selectAcpSession' | 'newSession' | 'acknowledgeAcpSessionCreated' | 'deleteSession' | 'renameSession' | 'updateSessionThinkingLevel' | 'cleanupEmptySession'> {
return {
loadSessions: async () => {
try {
@@ -132,19 +116,9 @@ export function createSessionActions(
if (data) {
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
const normalizedSessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
key: String(s.key || ''),
label: s.label ? String(s.label) : undefined,
displayName: s.displayName ? String(s.displayName) : undefined,
derivedTitle: s.derivedTitle ? String(s.derivedTitle) : undefined,
lastMessagePreview: s.lastMessagePreview ? String(s.lastMessagePreview) : undefined,
thinkingLevel: s.thinkingLevel ? String(s.thinkingLevel) : undefined,
model: s.model ? String(s.model) : undefined,
updatedAt: parseSessionUpdatedAtMs(s.updatedAt),
status: parseSessionStatus(s.status),
hasActiveRun: typeof s.hasActiveRun === 'boolean' ? s.hasActiveRun : undefined,
channel: s.lastChannel ? String(s.lastChannel) : undefined,
}));
const normalizedSessions: ChatSession[] = rawSessions.map(
(session: Record<string, unknown>) => normalizeGatewaySessionRow(session),
);
const sessions = normalizedSessions.filter((s: ChatSession) => shouldIncludeSessionInSidebarList(s));
const canonicalBySuffix = new Map<string, string>();
@@ -553,6 +527,72 @@ export function createSessionActions(
}));
},
updateSessionThinkingLevel: async (key: string, level: string | null) => {
const state = get();
if (state.thinkingLevelUpdatingSessionKey) {
throw new Error('A reasoning effort update is already in progress');
}
const previousSession = state.sessions.find((session) => session.key === key);
set((current) => ({
thinkingLevelUpdatingSessionKey: key,
sessions: current.sessions.map((session) => {
if (session.key !== key) return session;
if (level !== null) return { ...session, thinkingLevel: level };
const { thinkingLevel: _thinkingLevel, ...inheritedSession } = session;
return inheritedSession;
}),
}));
try {
const result = await hostApi.gateway.rpc<{
resolved?: {
thinkingLevel?: unknown;
thinkingLevels?: unknown;
};
}>('sessions.patch', { key, thinkingLevel: level });
const resolved = result?.resolved;
const resolvedPatch = normalizeGatewaySessionPatch({
key,
...(Array.isArray(resolved?.thinkingLevels)
? { thinkingLevels: resolved.thinkingLevels }
: {}),
});
const inheritedLevel = level === null && typeof resolved?.thinkingLevel === 'string'
? resolved.thinkingLevel.trim()
: '';
set((current) => ({
sessions: current.sessions.map((session) => {
if (session.key !== key) return session;
const next = { ...session };
if (level === null) delete next.thinkingLevel;
else next.thinkingLevel = level;
if (resolvedPatch.values.thinkingLevels) {
next.thinkingLevels = resolvedPatch.values.thinkingLevels;
}
if (inheritedLevel) next.thinkingDefault = inheritedLevel;
return next;
}),
}));
await get().loadSessions({ force: true });
} catch (error) {
set((current) => ({
sessions: previousSession
? current.sessions.map((session) => session.key === key ? previousSession : session)
: current.sessions,
}));
throw error;
} finally {
set((current) => ({
thinkingLevelUpdatingSessionKey:
current.thinkingLevelUpdatingSessionKey === key
? null
: current.thinkingLevelUpdatingSessionKey,
}));
}
},
// ── Cleanup empty session on navigate away ──
cleanupEmptySession: () => {
+15
View File
@@ -20,6 +20,7 @@ const STRING_FIELDS = [
'derivedTitle',
'lastMessagePreview',
'thinkingLevel',
'thinkingDefault',
'model',
'workspacePath',
] as const satisfies readonly SessionField[];
@@ -70,6 +71,20 @@ export function normalizeGatewaySessionPatch(raw: Record<string, unknown>): Norm
}
}
if (hasOwn(raw, 'thinkingLevels')) {
present.add('thinkingLevels');
if (raw.thinkingLevels === null) {
cleared.add('thinkingLevels');
} else if (Array.isArray(raw.thinkingLevels)) {
values.thinkingLevels = raw.thinkingLevels.flatMap((option) => {
if (!isRecord(option)) return [];
const id = typeof option.id === 'string' ? option.id.trim() : '';
const label = typeof option.label === 'string' ? option.label.trim() : '';
return id && label ? [{ id, label }] : [];
});
}
}
if (hasOwn(raw, 'updatedAt')) {
present.add('updatedAt');
if (raw.updatedAt === null) {
+1 -1
View File
@@ -9,7 +9,7 @@ export type ChatGet = () => ChatState;
export type SessionHistoryActions = Pick<
ChatState,
'loadSessions' | 'switchSession' | 'selectAcpSession' | 'newSession' | 'acknowledgeAcpSessionCreated' | 'deleteSession' | 'renameSession' | 'cleanupEmptySession' | 'loadHistory' | 'loadMoreHistory'
'loadSessions' | 'switchSession' | 'selectAcpSession' | 'newSession' | 'acknowledgeAcpSessionCreated' | 'deleteSession' | 'renameSession' | 'updateSessionThinkingLevel' | 'cleanupEmptySession' | 'loadHistory' | 'loadMoreHistory'
>;
export type RuntimeActions = Pick<
+51 -3
View File
@@ -12,6 +12,13 @@ test.describe('ClawX chat model picker', () => {
const { ipcMain } = process.mainModule!.require('electron') as typeof import('electron');
let currentModelRef = refs.alphaModelRef;
let currentThinkingLevel: string | null = null;
const thinkingLevels = [
{ id: 'low', label: 'Low' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' },
];
const hostRequests: Array<{ path: string; method: string; body: unknown }> = [];
const now = new Date().toISOString();
let releaseProviderAccounts: (() => void) | undefined;
@@ -57,7 +64,18 @@ test.describe('ClawX chat model picker', () => {
ipcMain.handle('gateway:rpc', async (_event: unknown, method: string, params: unknown) => {
hostRequests.push({ path: `gateway:${method}`, method: 'RPC', body: params ?? null });
if (method === 'sessions.list') {
return { success: true, result: { sessions: [{ key: 'agent:main:main', displayName: 'main' }] } };
return {
success: true,
result: {
sessions: [{
key: 'agent:main:main',
displayName: 'main',
thinkingLevel: currentThinkingLevel,
thinkingDefault: 'medium',
thinkingLevels,
}],
},
};
}
if (method === 'chat.history') {
return { success: true, result: { messages: [] } };
@@ -106,7 +124,28 @@ test.describe('ClawX chat model picker', () => {
const params = body?.params ?? null;
hostRequests.push({ path: `gateway:${method}`, method: 'RPC', body: params });
if (method === 'sessions.list') {
return makeResponse(request.id, { success: true, result: { sessions: [{ key: 'agent:main:main', displayName: 'main' }] } });
return makeResponse(request.id, {
sessions: [{
key: 'agent:main:main',
displayName: 'main',
thinkingLevel: currentThinkingLevel,
thinkingDefault: 'medium',
thinkingLevels,
}],
});
}
if (method === 'sessions.patch') {
const patch = params as { thinkingLevel?: string | null };
currentThinkingLevel = patch.thinkingLevel ?? null;
return makeResponse(request.id, {
ok: true,
key: 'agent:main:main',
entry: { thinkingLevel: currentThinkingLevel },
resolved: {
thinkingLevel: currentThinkingLevel ?? 'medium',
thinkingLevels,
},
});
}
if (method === 'chat.history') {
return makeResponse(request.id, { success: true, result: { messages: [] } });
@@ -233,7 +272,7 @@ test.describe('ClawX chat model picker', () => {
win?.webContents.send('gateway:status-changed', { state: 'running', port: 18789, pid: 12345, gatewayReady: true });
});
await expect(page.getByTestId('chat-model-picker-button')).toContainText('model-alpha (Alpha)');
await expect(page.getByTestId('chat-model-picker-button')).toContainText('model-alpha (Alpha) · Medium');
await page.getByTestId('chat-model-picker-button').click();
await expect(page.getByTestId('chat-model-picker-menu')).toBeVisible();
await expect(page.getByTestId('chat-model-picker-menu')).toContainText('provider/model-beta (Beta)');
@@ -245,6 +284,10 @@ test.describe('ClawX chat model picker', () => {
await expect(page.getByTestId('chat-model-picker-menu')).not.toContainText('moonshot/kimi-k2.7 (Moonshot)');
await page.getByTestId('chat-model-picker-menu').getByRole('button', { name: 'provider/model-beta (Beta)' }).click();
await expect(page.getByTestId('chat-model-picker-button')).toContainText('provider/model-beta (Beta)');
await page.getByTestId('chat-model-picker-button').click();
await expect(page.getByTestId('chat-model-picker-menu')).toContainText('Extra High');
await page.getByTestId('chat-reasoning-effort-option-xhigh').click();
await expect(page.getByTestId('chat-model-picker-button')).toContainText('provider/model-beta (Beta) · Extra High');
const requests = await app.evaluate(() => (
(globalThis as typeof globalThis & { __chatModelPickerRequests?: Array<{ path: string; method: string; body: unknown }> }).__chatModelPickerRequests ?? []
@@ -254,6 +297,11 @@ test.describe('ClawX chat model picker', () => {
method: 'PUT',
body: { modelRef: betaModelRef },
});
expect(requests).toContainEqual({
path: 'gateway:sessions.patch',
method: 'RPC',
body: { key: 'agent:main:main', thinkingLevel: 'xhigh' },
});
expect(requests.some((request) =>
request.path === '/api/gateway/restart'
|| request.path === '/api/gateway/start'
+108
View File
@@ -13,6 +13,10 @@ const { agentsState, chatState, gatewayState, providersState, artifactPanelMocks
},
chatState: {
currentAgentId: 'main',
currentSessionKey: 'agent:main:main',
sessions: [] as Array<Record<string, unknown>>,
thinkingLevelUpdatingSessionKey: null as string | null,
updateSessionThinkingLevel: vi.fn(),
},
gatewayState: {
status: { state: 'running', port: 18789 },
@@ -98,6 +102,18 @@ function translate(key: string, vars?: Record<string, unknown>): string {
return 'No matching skills found';
case 'composer.pickAgent':
return 'Choose agent';
case 'composer.modelControlTitle':
return 'Model and reasoning effort';
case 'composer.modelSectionTitle':
return 'Model';
case 'composer.reasoningEffortTitle':
return 'Reasoning effort';
case 'composer.reasoningEffortDefault':
return 'Default';
case 'composer.reasoningEffortInherited':
return `Inherited: ${String(vars?.level ?? '')}`;
case 'composer.reasoningEffortUpdateFailed':
return `Failed to update reasoning effort: ${String(vars?.error ?? '')}`;
case 'composer.clearTarget':
return 'Clear target agent';
case 'composer.targetChip':
@@ -224,6 +240,10 @@ describe('ChatInput agent targeting', () => {
agentsState.defaultModelRef = null;
agentsState.updateAgentModel.mockReset();
chatState.currentAgentId = 'main';
chatState.currentSessionKey = 'agent:main:main';
chatState.sessions = [];
chatState.thinkingLevelUpdatingSessionKey = null;
chatState.updateSessionThinkingLevel.mockReset();
gatewayState.status = { state: 'running', port: 18789 };
providersState.accounts = [];
providersState.statuses = [];
@@ -680,6 +700,94 @@ describe('ChatInput agent targeting', () => {
expect(await screen.findByText('No matching skills found')).toBeInTheDocument();
});
it('shows Gateway reasoning options in the model menu and patches the current session', async () => {
configureAgentAndModelPickers();
chatState.sessions = [{
key: chatState.currentSessionKey,
thinkingDefault: 'medium',
thinkingLevels: [
{ id: 'off', label: 'Off' },
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
{ id: 'xhigh', label: 'Extra High' },
],
}];
chatState.updateSessionThinkingLevel.mockResolvedValue(undefined);
renderChatInput();
expect(screen.getByTestId('chat-model-picker-button')).toHaveTextContent('gpt-a (Alpha) · Medium');
fireEvent.click(screen.getByTestId('chat-model-picker-button'));
expect(screen.getByText('Reasoning effort')).toBeInTheDocument();
expect(screen.getByTestId('chat-reasoning-effort-option-inherited')).toHaveTextContent('Inherited: Medium');
expect(screen.getByTestId('chat-reasoning-effort-option-xhigh')).toHaveTextContent('Extra High');
fireEvent.click(screen.getByTestId('chat-reasoning-effort-option-high'));
await waitFor(() => {
expect(chatState.updateSessionThinkingLevel).toHaveBeenCalledWith('agent:main:main', 'high');
});
});
it('keeps send disabled while a reasoning effort patch is in flight', () => {
configureAgentAndModelPickers();
chatState.sessions = [{
key: chatState.currentSessionKey,
thinkingLevel: 'high',
thinkingDefault: 'medium',
thinkingLevels: [{ id: 'high', label: 'High' }],
}];
chatState.thinkingLevelUpdatingSessionKey = chatState.currentSessionKey;
renderChatInput();
fireEvent.change(screen.getByTestId('chat-composer-input'), { target: { value: 'Wait for effort' } });
expect(screen.getByTestId('chat-composer-send')).toBeDisabled();
expect(screen.getByTestId('chat-model-picker-button')).toBeDisabled();
});
it('clears the current-session override when inherited effort is selected', async () => {
configureAgentAndModelPickers();
chatState.sessions = [{
key: chatState.currentSessionKey,
thinkingLevel: 'high',
thinkingDefault: 'medium',
thinkingLevels: [
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
],
}];
chatState.updateSessionThinkingLevel.mockResolvedValue(undefined);
renderChatInput();
fireEvent.click(screen.getByTestId('chat-model-picker-button'));
fireEvent.click(screen.getByTestId('chat-reasoning-effort-option-inherited'));
await waitFor(() => {
expect(chatState.updateSessionThinkingLevel).toHaveBeenCalledWith('agent:main:main', null);
});
});
it('reports a failed reasoning effort patch', async () => {
configureAgentAndModelPickers();
chatState.sessions = [{
key: chatState.currentSessionKey,
thinkingDefault: 'medium',
thinkingLevels: [{ id: 'high', label: 'High' }],
}];
chatState.updateSessionThinkingLevel.mockRejectedValue(new Error('patch failed'));
renderChatInput();
fireEvent.click(screen.getByTestId('chat-model-picker-button'));
fireEvent.click(screen.getByTestId('chat-reasoning-effort-option-high'));
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith(
'Failed to update reasoning effort: Error: patch failed',
);
});
});
it('closes the focused skill picker search with Escape', async () => {
configureAgentAndModelPickers();
@@ -42,6 +42,9 @@ vi.mock('@/stores/agents', () => ({
vi.mock('@/lib/host-api', () => ({
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
hostApi: {
gateway: {
rpc: (...args: unknown[]) => gatewayRpcMock(...args),
},
media: {
thumbnails: vi.fn(async () => ({})),
},
@@ -110,6 +113,88 @@ describe('chat store session label summary hydration', () => {
);
});
it('persists and refreshes a current-session thinking override through the Gateway', async () => {
const sessionKey = 'agent:main:thinking';
gatewayRpcMock.mockImplementation(async (method: string, params?: Record<string, unknown>) => {
if (method === 'sessions.patch') {
expect(params).toEqual({ key: sessionKey, thinkingLevel: 'high' });
return {
ok: true,
key: sessionKey,
entry: { thinkingLevel: 'high' },
resolved: {
thinkingLevel: 'high',
thinkingLevels: [
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
],
},
};
}
if (method === 'sessions.list') {
return {
sessions: [{
key: sessionKey,
thinkingLevel: 'high',
thinkingDefault: 'medium',
thinkingLevels: [
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
],
}],
};
}
return {};
});
const { useChatStore } = await import('@/stores/chat');
useChatStore.setState({
currentSessionKey: sessionKey,
sessions: [{
key: sessionKey,
thinkingDefault: 'medium',
thinkingLevels: [{ id: 'medium', label: 'Medium' }],
}],
thinkingLevelUpdatingSessionKey: null,
});
await useChatStore.getState().updateSessionThinkingLevel(sessionKey, 'high');
expect(useChatStore.getState().thinkingLevelUpdatingSessionKey).toBeNull();
expect(useChatStore.getState().sessions).toContainEqual(expect.objectContaining({
key: sessionKey,
thinkingLevel: 'high',
thinkingDefault: 'medium',
thinkingLevels: [
{ id: 'medium', label: 'Medium' },
{ id: 'high', label: 'High' },
],
}));
});
it('rolls back the session row when a thinking override patch fails', async () => {
const sessionKey = 'agent:main:thinking-failure';
gatewayRpcMock.mockRejectedValue(new Error('patch rejected'));
const { useChatStore } = await import('@/stores/chat');
const previous = {
key: sessionKey,
thinkingLevel: 'medium',
thinkingDefault: 'low',
thinkingLevels: [{ id: 'medium', label: 'Medium' }],
};
useChatStore.setState({
currentSessionKey: sessionKey,
sessions: [previous],
thinkingLevelUpdatingSessionKey: null,
});
await expect(
useChatStore.getState().updateSessionThinkingLevel(sessionKey, 'high'),
).rejects.toThrow('patch rejected');
expect(useChatStore.getState().thinkingLevelUpdatingSessionKey).toBeNull();
expect(useChatStore.getState().sessions).toEqual([previous]);
});
it('only includes persisted main sessions missing workspacePath when workspace hydration is requested', async () => {
const { getSessionLabelHydrationCandidate } = await import('@/stores/chat/session-label-hydration');
+71
View File
@@ -986,10 +986,81 @@ describe('syncProviderConfigToOpenClaw', () => {
expect.objectContaining({
id: 'gpt-5.5',
contextWindow: 1000000,
reasoning: true,
compat: {
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
},
}),
]);
});
it('backfills the reasoning effort ladder on an existing GLM-5.2 custom model', async () => {
await writeOpenClawJson({
models: {
providers: {
'custom-example': {
baseUrl: 'https://example.com/v1',
api: 'openai-completions',
models: [
{ id: 'glm-5.2', name: 'glm-5.2', input: ['text'], contextWindow: 1000000 },
],
},
},
},
});
const { syncProviderConfigToOpenClaw } = await import('@electron/utils/openclaw-auth');
await syncProviderConfigToOpenClaw('custom-example', 'glm-5.2', {
baseUrl: 'https://example.com/v1',
api: 'openai-completions',
});
const result = await readOpenClawJson();
const providers = (result.models as Record<string, unknown>).providers as Record<string, unknown>;
const entry = providers['custom-example'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(models).toEqual([
expect.objectContaining({
id: 'glm-5.2',
reasoning: true,
compat: {
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
},
}),
]);
});
it('preserves an explicit non-reasoning override on a custom model', async () => {
await writeOpenClawJson({
models: {
providers: {
'custom-example': {
baseUrl: 'https://example.com/v1',
api: 'openai-completions',
models: [
{ id: 'glm-5.2', name: 'glm-5.2', reasoning: false, contextWindow: 1000000 },
],
},
},
},
});
const { syncProviderConfigToOpenClaw } = await import('@electron/utils/openclaw-auth');
await syncProviderConfigToOpenClaw('custom-example', 'glm-5.2', {
baseUrl: 'https://example.com/v1',
api: 'openai-completions',
});
const result = await readOpenClawJson();
const providers = (result.models as Record<string, unknown>).providers as Record<string, unknown>;
const entry = providers['custom-example'] as Record<string, unknown>;
const models = entry.models as Array<Record<string, unknown>>;
expect(models[0]).toMatchObject({ id: 'glm-5.2', reasoning: false });
expect(models[0]).not.toHaveProperty('compat');
});
it('does not overwrite an existing contextWindow on re-sync', async () => {
await writeOpenClawJson({
models: {
@@ -6,6 +6,7 @@ import {
LOCAL_MODEL_CONTEXT_WINDOW,
inferCustomModelContextWindow,
inferCustomModelInputModalities,
inferCustomModelReasoningCapabilities,
} from '@electron/shared/providers/model-capabilities';
describe('inferCustomModelInputModalities', () => {
@@ -161,3 +162,31 @@ describe('inferCustomModelContextWindow', () => {
});
});
});
describe('inferCustomModelReasoningCapabilities', () => {
it.each([
'glm-5.2',
'vendor/glm-5.3',
'gpt-5.5',
'openai/gpt-5.6-sol',
])('exposes the standard effort ladder for hosted model %s', (modelId) => {
expect(inferCustomModelReasoningCapabilities(modelId, {
providerKey: 'custom-a1b2c3d4',
apiProtocol: 'openai-completions',
})).toEqual({
reasoning: true,
compat: {
supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'],
},
});
});
it.each([
['glm-5.1', 'custom-a1b2c3d4', 'openai-completions'],
['unknown-private-model', 'custom-a1b2c3d4', 'openai-completions'],
['glm-5.2', 'ollama-a1b2c3d4', 'openai-completions'],
['glm-5.2', 'custom-a1b2c3d4', 'anthropic-messages'],
])('does not invent reasoning controls for %s on %s / %s', (modelId, providerKey, apiProtocol) => {
expect(inferCustomModelReasoningCapabilities(modelId, { providerKey, apiProtocol })).toBeUndefined();
});
});
+14 -2
View File
@@ -15,7 +15,14 @@ describe('Gateway session catalog projection', () => {
displayName: 'Display',
derivedTitle: 'Derived',
lastMessagePreview: 'Preview',
thinkingLevel: 'high',
thinkingLevel: 'off',
thinkingDefault: 'medium',
thinkingLevels: [
{ id: 'off', label: 'Off' },
{ id: 'high', label: 'High' },
{ id: '', label: 'Invalid' },
{ id: 'missing-label' },
],
model: 'model-a',
updatedAt: '2026-07-20T00:00:00.000Z',
status: ' RUNNING ',
@@ -33,7 +40,12 @@ describe('Gateway session catalog projection', () => {
displayName: 'Display',
derivedTitle: 'Derived',
lastMessagePreview: 'Preview',
thinkingLevel: 'high',
thinkingLevel: 'off',
thinkingDefault: 'medium',
thinkingLevels: [
{ id: 'off', label: 'Off' },
{ id: 'high', label: 'High' },
],
model: 'model-a',
updatedAt: Date.parse('2026-07-20T00:00:00.000Z'),
status: 'running',