diff --git a/README.ja-JP.md b/README.ja-JP.md index 9847631c..d589adb5 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -106,7 +106,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています `@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。 セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。セッション行の削除と画面遷移は完全削除が成功した後にのみ行われ、失敗した場合は会話と確認ダイアログが保持されるため再試行できます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。 各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。 -コンポーザーのモデルメニューでは、現在のモデルについて OpenClaw が公開する推論強度を専用サブメニューに表示します。カスタムモデルで新しいチャットを作成した場合も、最初の送信前は Gateway が公開する現在の Agent の既定値を使ってこのコントロールを表示し、セッション作成後はセッションメタデータを優先します。選択した強度は現在のセッションに適用され、「思考」トグルは明示的な Off と以前の選択または利用可能な有効レベルを切り替えます。 +コンポーザーのモデルメニューでは、Off、Low、Medium、High の4段階の推論強度を専用サブメニューから直接選択できます。カスタムモデルで新しいチャットを作成した場合も、最初の送信前は Gateway が公開する現在の Agent の既定値を使ってこのコントロールを表示し、セッション作成後はセッションメタデータを優先します。選択した強度は現在のセッションに適用され、Off を選ぶと個別の「思考」トグルなしで推論を無効にできます。Provider が応答前にリクエストを予期せず中断した場合、チャットはターンを黙って終了せず、再試行可能なエラーを表示します。 Chat の右パネルにあるワークスペースとプレビューの各タブでは、Markdown、`.docx`、`.pptx` ファイルを読み取り専用でプレビューできます。Markdown ファイルのプレビューでは、同じシンタックスハイライト、ソフトラップ、コピー操作付きのフェンスコード、CJK 対応の解析、KaTeX 数式を静的レンダリングモードでサポートします。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。 @@ -136,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" エラーになる前に圧縮できます。Custom プロバイダーのプライマリモデルは推論を有効化し、Low / Medium / High / Extra High の固定ラダーを同期するため、チャットでセッションの thinking を設定できます。ClawX はモデル名から別のレベル一覧を推測しません。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"` と `reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが設定したフィールドは上書きされず、欠けている推論可能なフィールドだけが補完されます。 +Custom プロバイダーのモデル行には明示的な `contextWindow` も書き込まれ(モデルファミリーから推定、例:`gpt-5.x` → 272k)、旧バージョンで保存された行は起動時に自動補完されます。これにより OpenClaw は長いセッションを "Context overflow" エラーになる前に圧縮できます。Custom プロバイダーのプライマリモデルは推論を有効化し、Low / Medium / High の固定ラダーを同期するため、チャットでセッションの thinking を設定できます。ClawX はモデル名から別のレベル一覧を推測しません。compaction 未設定の場合は `agents.defaults.compaction.mode = "safeguard"` と `reserveTokensFloor = 50000` が既定値として設定されますが、ユーザーが設定したフィールドは上書きされず、欠けている推論可能なフィールドだけが補完されます。 Z.AI(CN / 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` プローブへ自動フォールバックします。 diff --git a/README.md b/README.md index 5fb4d2ed..a66740f5 100644 --- a/README.md +++ b/README.md @@ -106,7 +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. A session row is removed and navigation changes only after permanent deletion succeeds; failed deletions leave the conversation and confirmation open for retry. 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 opens reasoning effort in a dedicated submenu using only levels advertised by OpenClaw for the current model. This control is also available on a new custom-model chat before its first message, using the current agent defaults advertised by Gateway until the session is created. A selected level applies to the current session, and the Thinking toggle switches between explicit Off and the prior or an available enabled level. +The composer model menu opens reasoning effort in a dedicated submenu with four directly selectable levels: Off, Low, Medium, and High. This control is also available on a new custom-model chat before its first message, using the current agent defaults advertised by Gateway until the session is created. A selected level applies to the current session; Off disables reasoning without a separate Thinking toggle. If a provider unexpectedly aborts a prompt before replying, Chat now shows a retryable error instead of silently ending the turn. The Workspace and Preview tabs in Chat's right panel provide read-only previews for Markdown, `.docx`, and `.pptx` files. Markdown file previews use the same syntax-highlighted, soft-wrapped, copyable fenced code, CJK-aware parsing, and KaTeX math support in static rendering mode. 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. @@ -136,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. Custom-provider primary models are synced with reasoning enabled and a fixed Low / Medium / High / Extra High ladder so Chat can offer session thinking controls; ClawX does not invent a different list from the model name. 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). +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. Custom-provider primary models are synced with reasoning enabled and a fixed Low / Medium / High ladder so Chat can offer session thinking controls; ClawX does not invent a different list from the model name. 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. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3d3c0af5..adc72584 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -107,7 +107,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。 会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。只有永久删除成功后,会话行才会移除且页面才会跳转;删除失败时会保留会话与确认框,方便重试。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。 每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。 -输入框的模型菜单会在独立子菜单中显示 OpenClaw 针对当前模型提供的推理强度。使用自定义模型新建对话时,首次发送前也会根据 Gateway 提供的当前 Agent 默认值显示该控件;会话创建后则以会话元数据为准。所选强度仅对当前会话持续生效;“思考”开关则在显式关闭与此前选择或可用的启用档位之间切换。 +输入框的模型菜单会在独立子菜单中提供四个可直接选择的推理强度:Off、Low、Medium 和 High。使用自定义模型新建对话时,首次发送前也会根据 Gateway 提供的当前 Agent 默认值显示该控件;会话创建后则以会话元数据为准。所选强度仅对当前会话持续生效;选择 Off 即可关闭推理,不再提供单独的“思考”开关。如果 Provider 在回复前意外中止请求,聊天界面会显示可重试错误,而不是静默结束本轮。 Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 Markdown、`.docx` 和 `.pptx` 文件。Markdown 文件预览以静态渲染模式提供相同的围栏代码语法高亮、软换行与复制操作、面向中日韩文本的解析和 KaTeX 数学公式支持。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。 @@ -137,7 +137,7 @@ Skills 页面可展示来自多个 OpenClaw 来源的技能(托管目录、wor 在开发者模式下,独立的“图像生成”页面支持配置 OpenAI 兼容生图端点(Base URL、API Key 和模型名,例如 `gpt-image-2`),生图请求会走专用的 `/v1/images/generations` 服务,聊天仍继续使用正常的 OpenAI Provider。 如果你通过 **自定义(Custom)Provider** 对接 OpenAI-compatible 网关,可以在 **设置 → AI Providers → 编辑 Provider** 中配置自定义 `User-Agent`,以提高兼容性。 编辑或切换 Provider 时,ClawX 会保留已有的模型级能力元数据,例如 `input: ["text", "image"]`。新选择的自定义 Provider 模型会使用与 OpenClaw onboarding 一致的图片输入能力推断;未知模型默认按纯文本模型处理。 -自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。自定义 Provider 的主模型会同步开启推理,并写入固定的低 / 中 / 高 / 超高档位,以便聊天侧可设置会话 thinking;ClawX 不会根据模型名称推断另一套档位。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"` 和 `reserveTokensFloor = 50000`;你手动配置过的字段不会被覆盖,仅会回填缺失的推断字段。 +自定义 Provider 的模型行还会写入显式的 `contextWindow`(按模型系列推断,例如 `gpt-5.x` → 272k),旧版本保存的模型行会在启动时自动回填,使 OpenClaw 能在长会话超限前主动压缩上下文,避免出现 "Context overflow" 报错。自定义 Provider 的主模型会同步开启推理,并写入固定的低 / 中 / 高档位,以便聊天侧可设置会话 thinking;ClawX 不会根据模型名称推断另一套档位。当你没有配置 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` 探测。 diff --git a/electron/services/acp-chat-service.ts b/electron/services/acp-chat-service.ts index f346ab14..47cda5cd 100644 --- a/electron/services/acp-chat-service.ts +++ b/electron/services/acp-chat-service.ts @@ -16,6 +16,7 @@ import { HOST_EVENT_CHANNELS } from '@shared/host-events/contract'; import type { AcpChatCancelPayload, AcpChatLoadPayload, + AcpChatOperationErrorCode, AcpChatOperationResult, AcpChatPromptPayload, AcpChatRespondPermissionPayload, @@ -69,8 +70,12 @@ function ok(generation?: number, sessionUpdates?: AcpSessionUpdateEnvelope[]): A }; } -function fail(error: unknown): AcpChatOperationResult { - return { success: false, error: error instanceof Error ? error.message : String(error) }; +function fail(error: unknown, errorCode?: AcpChatOperationErrorCode): AcpChatOperationResult { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + ...(errorCode ? { errorCode } : {}), + }; } function cancelledPermissionResponse(): RequestPermissionResponse { @@ -143,6 +148,7 @@ export class AcpChatService { private loadQueue: Promise | null = null; private activeLoadBatch: AcpSessionLoadBatch | null = null; private readonly livePrompts = new Map(); + private readonly clientCancelledPromptSessions = new Set(); private permissionSeq = 0; private readonly permissionWaiters = new Map(); readonly client: Client; @@ -371,6 +377,7 @@ export class AcpChatService { generation, accessGrant, }; + this.clientCancelledPromptSessions.delete(payload.sessionKey); this.livePrompts.set(payload.sessionKey, promptContext); try { const promptCwd = payload.cwd === accessGrant.executionCwd @@ -395,7 +402,7 @@ export class AcpChatService { this.permissionsEnabled = true; const messageId = payload.messageId ?? randomUUID(); const isSlashCommand = payload.message?.trimStart().startsWith('/') === true; - await connection.prompt({ + const response = await connection.prompt({ sessionId: acpSessionId, prompt, // ACP 1.1 removed messageId from the PromptRequest wire shape. Keep @@ -404,10 +411,21 @@ export class AcpChatService { // so the Gateway can classify and fold command replies into chat final. _meta: { sessionKey: payload.sessionKey, prefixCwd: !isSlashCommand, messageId }, }); + const cancelledByClient = this.clientCancelledPromptSessions.has(payload.sessionKey); + if (response.stopReason === 'cancelled' && !cancelledByClient) { + const error = 'ACP prompt was aborted before producing a response'; + logger.warn(`[acp-chat] ${error}`); + this.trace('session/prompt:failed', { + sessionKey: payload.sessionKey, + generation, + details: { error, stopReason: response.stopReason }, + }); + return fail(error, 'prompt_aborted'); + } this.trace('session/prompt:success', { sessionKey: payload.sessionKey, generation, - details: { blockCount: prompt.length, acpSessionId }, + details: { blockCount: prompt.length, acpSessionId, stopReason: response.stopReason }, }); return ok(generation); } catch (error) { @@ -422,6 +440,7 @@ export class AcpChatService { this.livePrompts.delete(payload.sessionKey); this.resolvePermissionWaitersForSession(payload.sessionKey, cancelledPermissionResponse()); } + this.clientCancelledPromptSessions.delete(payload.sessionKey); this.permissionsEnabled = this.activeSessionKey != null && this.livePrompts.has(this.activeSessionKey); } } @@ -430,6 +449,8 @@ export class AcpChatService { if (!isValidSessionKey(payload.sessionKey)) return fail('Invalid ACP cancel payload'); if (payload.sessionKey !== this.activeSessionKey || !this.loadedAcpSessionId) return fail('ACP session is not loaded'); + const hasLivePrompt = this.livePrompts.has(payload.sessionKey); + if (hasLivePrompt) this.clientCancelledPromptSessions.add(payload.sessionKey); try { this.trace('session/cancel:start', { sessionKey: payload.sessionKey }); const connection = await this.ensureConnection(); @@ -439,6 +460,7 @@ export class AcpChatService { this.trace('session/cancel:success', { sessionKey: payload.sessionKey }); return ok(this.generation); } catch (error) { + if (hasLivePrompt) this.clientCancelledPromptSessions.delete(payload.sessionKey); logger.error(`[acp-chat] cancel failed: ${String(error)}`); this.trace('session/cancel:failed', { sessionKey: payload.sessionKey, @@ -593,6 +615,7 @@ export class AcpChatService { this.historicalGeneration = null; this.permissionsEnabled = false; this.livePrompts.clear(); + this.clientCancelledPromptSessions.clear(); } private emitSessionUpdate(notification: SessionNotification): void { diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts index 52661117..a33eaecb 100644 --- a/electron/shared/providers/types.ts +++ b/electron/shared/providers/types.ts @@ -104,7 +104,6 @@ export const CUSTOM_PROVIDER_DEFAULT_REASONING_EFFORTS: ProviderReasoningEffort[ 'low', 'medium', 'high', - 'xhigh', ]; export type ProviderAuthMode = diff --git a/harness/specs/rules/acp-chat-state-and-history.md b/harness/specs/rules/acp-chat-state-and-history.md index 61334e6d..4940b2c0 100644 --- a/harness/specs/rules/acp-chat-state-and-history.md +++ b/harness/specs/rules/acp-chat-state-and-history.md @@ -10,7 +10,7 @@ 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 each ordinary live prompt update continues through host events and is applied immediately without a Renderer batching timer. 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 effective fallback, and `thinkingLevel` as the explicit override. For a Renderer-local draft that Gateway has not listed yet, it may temporarily use the agent-scoped `sessions.list.defaults` thinking metadata returned by the same catalog request; persisted rows take precedence as soon as they exist. Its effort submenu lists concrete levels only (no inherited option) and may expose a Thinking toggle only when `off` is advertised: disabling patches explicit `off`, while enabling restores the prior non-off override, a non-off runtime default, or an advertised enabled level (preferring `medium`) when the runtime default is also `off`. 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. +Current-session reasoning effort is Gateway session metadata, not ACP timeline state. The Chat picker must use the selected row's `thinkingLevels` as its capability source, `thinkingDefault` as the effective fallback, and `thinkingLevel` as the explicit override. For a Renderer-local draft that Gateway has not listed yet, it may temporarily use the agent-scoped `sessions.list.defaults` thinking metadata returned by the same catalog request; persisted rows take precedence as soon as they exist. Its effort submenu directly lists the advertised members of the product ladder `off`, `low`, `medium`, and `high` in that order, with no inherited option and no separate Thinking toggle. Selecting `off` patches that explicit level through `sessions.patch`; `null` remains reserved for clearing the override. Renderer must not derive support from provider or model-name patterns, and sending must wait until an in-flight picker patch settles. If ACP reports a cancelled prompt without a matching user-initiated cancel, Main must return a typed prompt-aborted failure so Renderer shows a localized retryable error instead of silently completing an empty turn. 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. diff --git a/harness/specs/rules/provider-model-metadata-preservation.md b/harness/specs/rules/provider-model-metadata-preservation.md index df43f7df..a2da7dc7 100644 --- a/harness/specs/rules/provider-model-metadata-preservation.md +++ b/harness/specs/rules/provider-model-metadata-preservation.md @@ -25,7 +25,7 @@ user-owned and must never be modified, and non-`custom-` provider entries are never backfilled. Custom-provider primary model rows receive `reasoning: true` and the fixed -effort ladder `compat.supportedReasoningEfforts: ["low","medium","high","xhigh"]` +effort ladder `compat.supportedReasoningEfforts: ["low","medium","high"]` so Chat can offer session thinking controls. Provider settings must not expose enable-reasoning controls, and model-name inference must not invent a different effort list. Sync must preserve unrelated compat keys while writing this ladder. diff --git a/harness/specs/scenarios/gateway-backend-communication.md b/harness/specs/scenarios/gateway-backend-communication.md index dd6aedab..d3cc42b1 100644 --- a/harness/specs/scenarios/gateway-backend-communication.md +++ b/harness/specs/scenarios/gateway-backend-communication.md @@ -95,4 +95,4 @@ The local HTML Preview privileged bridge is also Main-owned: Renderer may load a 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. A local draft missing from the catalog may use only the agent-scoped `sessions.list.defaults` thinking metadata from that request until Gateway returns a persisted row. Renderer must not invent thinking levels from model names. Custom-provider primary models are synced with a fixed OpenClaw reasoning ladder so Gateway can advertise levels to Chat; provider settings do not expose enable-reasoning controls. +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. A local draft missing from the catalog may use only the agent-scoped `sessions.list.defaults` thinking metadata from that request until Gateway returns a persisted row. Renderer must not invent thinking levels from model names. Custom-provider primary models are synced with the fixed `low`, `medium`, and `high` OpenClaw reasoning ladder so Gateway can advertise those levels to Chat; the picker adds advertised `off` as the direct way to disable reasoning and does not expose a separate toggle. Provider settings do not expose enable-reasoning controls. An ACP prompt that is unexpectedly reported as cancelled must cross the typed host boundary as a prompt-aborted failure and produce a localized retryable Renderer error; an explicit user cancellation remains a successful stop action. diff --git a/harness/specs/tasks/chat-session-reasoning-effort.md b/harness/specs/tasks/chat-session-reasoning-effort.md index de9da157..3990769b 100644 --- a/harness/specs/tasks/chat-session-reasoning-effort.md +++ b/harness/specs/tasks/chat-session-reasoning-effort.md @@ -12,11 +12,14 @@ touchedAreas: - electron/shared/providers/model-capabilities.ts - electron/shared/providers/types.ts - electron/services/providers/provider-runtime-sync.ts + - electron/services/acp-chat-service.ts - electron/utils/openclaw-auth.ts + - shared/acp-chat/types.ts - shared/chat/types.ts - shared/host-api/contract.ts - src/components/settings/ProvidersSettings.tsx - src/lib/providers.ts + - src/stores/acp-chat-session.ts - src/stores/chat.ts - src/stores/chat/session-catalog.ts - src/stores/providers.ts @@ -28,6 +31,8 @@ touchedAreas: - tests/unit/chat-input.test.tsx - tests/unit/chat-store-session-label-fetch.test.ts - tests/unit/chat-load-sessions-startup.test.ts + - tests/unit/acp-chat-service.test.ts + - tests/unit/acp-chat-store.test.ts - tests/unit/gateway-events.test.ts - tests/unit/provider-runtime-sync.test.ts - tests/unit/provider-service-stale-cleanup.test.ts @@ -38,12 +43,12 @@ touchedAreas: - 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. + - The model menu directly offers the advertised members of the Off, Low, Medium, and High product ladder. - A new local draft can use the current agent's Gateway-advertised session defaults before its first prompt creates a persisted session row. - - Reasoning effort opens in a dedicated submenu with a current-session Thinking toggle. - - Selecting a level persists an explicit current-session override through Gateway sessions.patch. - - Disabling Thinking patches the explicit off level; enabling it restores the prior selection, a non-off runtime default, or an advertised enabled level when the runtime default is off. + - Reasoning effort opens in a dedicated submenu without a separate Thinking toggle. + - Selecting Off, Low, Medium, or High persists that explicit current-session override through Gateway sessions.patch. - A message cannot be sent while an effort change is still being applied. + - A provider-side aborted prompt that was not cancelled by the user surfaces a localized retryable error instead of ending silently. requiredProfiles: - fast - comms @@ -63,6 +68,8 @@ requiredTests: - tests/unit/chat-input.test.tsx - tests/unit/chat-store-session-label-fetch.test.ts - tests/unit/chat-load-sessions-startup.test.ts + - tests/unit/acp-chat-service.test.ts + - tests/unit/acp-chat-store.test.ts - tests/unit/gateway-events.test.ts - tests/unit/provider-runtime-sync.test.ts - tests/unit/provider-service-stale-cleanup.test.ts diff --git a/shared/acp-chat/types.ts b/shared/acp-chat/types.ts index 6dd9747d..b9544ad9 100644 --- a/shared/acp-chat/types.ts +++ b/shared/acp-chat/types.ts @@ -38,9 +38,12 @@ export type AcpChatRespondPermissionPayload = AcpSessionKeyPayload & { outcome: RequestPermissionResponse['outcome']; }; +export type AcpChatOperationErrorCode = 'prompt_aborted'; + export type AcpChatOperationResult = { success: boolean; error?: string; + errorCode?: AcpChatOperationErrorCode; generation?: number; /** The requested session still has a live prompt and was reactivated without history replay. */ resumedActivePrompt?: boolean; diff --git a/shared/i18n/locales/en/chat.json b/shared/i18n/locales/en/chat.json index bb437e8c..751ce470 100644 --- a/shared/i18n/locales/en/chat.json +++ b/shared/i18n/locales/en/chat.json @@ -61,6 +61,7 @@ "cancelled": "Cancelled", "loadFailed": "Failed to load session", "promptFailed": "Failed to send prompt", + "promptAborted": "The model request ended before producing a response. Please try again.", "unsupportedContent": "Unsupported content", "dismiss": "Dismiss", "image": "Image", @@ -227,8 +228,6 @@ "reasoningEffortDescription": "Higher effort gives more thorough responses, but takes longer and uses context faster.", "reasoningEffortDefault": "Default", "reasoningEffortUpdateFailed": "Failed to update reasoning effort: {{error}}", - "thinkingToggleTitle": "Thinking", - "thinkingToggleDescription": "Use reasoning for complex tasks", "gatewayDisconnectedPlaceholder": "Gateway not connected...", "send": "Send", "stop": "Stop", diff --git a/shared/i18n/locales/ja/chat.json b/shared/i18n/locales/ja/chat.json index aa2108ea..b579eed5 100644 --- a/shared/i18n/locales/ja/chat.json +++ b/shared/i18n/locales/ja/chat.json @@ -61,6 +61,7 @@ "cancelled": "キャンセル済み", "loadFailed": "セッションの読み込みに失敗しました", "promptFailed": "プロンプトの送信に失敗しました", + "promptAborted": "モデルリクエストは応答を生成する前に中断されました。もう一度お試しください。", "unsupportedContent": "未対応のコンテンツ", "dismiss": "閉じる", "image": "画像", @@ -224,8 +225,6 @@ "reasoningEffortDescription": "高い強度ほど回答は詳細になりますが、時間がかかり、コンテキストをより速く消費します。", "reasoningEffortDefault": "デフォルト", "reasoningEffortUpdateFailed": "推論強度の更新に失敗しました: {{error}}", - "thinkingToggleTitle": "思考", - "thinkingToggleDescription": "複雑なタスクで推論を使用", "clearTarget": "送信先 Agent をクリア", "targetChip": "@{{agent}}", "agentPickerTitle": "次のメッセージを別の Agent に直接送信します", diff --git a/shared/i18n/locales/ru/chat.json b/shared/i18n/locales/ru/chat.json index 0ccad817..fd1e6aec 100644 --- a/shared/i18n/locales/ru/chat.json +++ b/shared/i18n/locales/ru/chat.json @@ -61,6 +61,7 @@ "cancelled": "Отменено", "loadFailed": "Не удалось загрузить сеанс", "promptFailed": "Не удалось отправить запрос", + "promptAborted": "Запрос к модели был прерван до получения ответа. Повторите попытку.", "unsupportedContent": "Неподдерживаемое содержимое", "dismiss": "Закрыть", "image": "Изображение", @@ -224,8 +225,6 @@ "reasoningEffortDescription": "Более высокий уровень даёт более подробные ответы, но требует больше времени и быстрее расходует контекст.", "reasoningEffortDefault": "По умолчанию", "reasoningEffortUpdateFailed": "Не удалось изменить глубину рассуждений: {{error}}", - "thinkingToggleTitle": "Рассуждение", - "thinkingToggleDescription": "Использовать рассуждение для сложных задач", "clearTarget": "Очистить целевого агента", "targetChip": "@{{agent}}", "agentPickerTitle": "Направить следующее сообщение другому агенту", diff --git a/shared/i18n/locales/zh/chat.json b/shared/i18n/locales/zh/chat.json index 191903cc..1a8767f3 100644 --- a/shared/i18n/locales/zh/chat.json +++ b/shared/i18n/locales/zh/chat.json @@ -61,6 +61,7 @@ "cancelled": "已取消", "loadFailed": "会话加载失败", "promptFailed": "发送提示失败", + "promptAborted": "模型请求在生成回复前已中止,请重试。", "unsupportedContent": "不支持的内容", "dismiss": "关闭", "image": "图片", @@ -224,8 +225,6 @@ "reasoningEffortDescription": "更高档位会带来更深入的回答,但耗时更长,也会更快消耗上下文。", "reasoningEffortDefault": "默认", "reasoningEffortUpdateFailed": "推理强度更新失败:{{error}}", - "thinkingToggleTitle": "思考", - "thinkingToggleDescription": "为复杂任务启用推理", "clearTarget": "清除目标 Agent", "targetChip": "@{{agent}}", "agentPickerTitle": "将下一条消息直接发送给其他 Agent", diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index ba44c628..a87072cd 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -9,7 +9,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, FolderOpen, Loader2, AtSign, Search, ChevronDown, ChevronRight, Check } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { hostApi } from '@/lib/host-api'; @@ -64,6 +63,7 @@ interface ChatInputProps { // ── Helpers ────────────────────────────────────────────────────── const DIRECTORY_MIME_TYPE = 'application/x-directory'; +const SELECTABLE_THINKING_LEVEL_IDS = ['off', 'low', 'medium', 'high'] as const; function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -230,7 +230,6 @@ export function ChatInput({ const pickerRef = useRef(null); const skillPickerRef = useRef(null); const modelPickerRef = useRef(null); - const lastEnabledThinkingLevelRef = useRef(null); const workspaceMenuRef = useRef(null); const isComposingRef = useRef(false); const gatewayStatus = useGatewayStore((s) => s.status); @@ -300,15 +299,15 @@ export function ChatInput({ thinkingDefaults?.thinkingLevels, ]); const effortOptions = useMemo( - () => thinkingOptions.filter((option) => option.id !== 'off' && option.id !== 'minimal'), + () => SELECTABLE_THINKING_LEVEL_IDS.flatMap((id) => { + const option = thinkingOptions.find((candidate) => candidate.id === id); + return option ? [option] : []; + }), [thinkingOptions], ); - const supportsThinkingToggle = thinkingOptions.some((option) => option.id === 'off') - && effortOptions.length > 0; const effectiveThinkingLevel = currentSession?.thinkingLevel ?? currentSession?.thinkingDefault ?? (canUseThinkingDefaults ? thinkingDefaults?.thinkingDefault : undefined); - const thinkingEnabled = effectiveThinkingLevel !== 'off'; const currentThinkingLabel = useMemo(() => { if (!canConfigureThinking || !effectiveThinkingLevel) { return ''; @@ -338,7 +337,7 @@ export function ChatInput({ ); }, [quickSkills, skillQuery]); const showAgentPicker = mentionableAgents.length > 0; - const showModelPicker = modelOptions.length > 1 || thinkingOptions.length > 0; + const showModelPicker = modelOptions.length > 1 || effortOptions.length > 0; const chatComposerStatusComponents = rendererExtensionRegistry.getChatComposerStatusComponents(); const isGatewayUsable = gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false; const inputDisabled = disabled; @@ -383,11 +382,6 @@ export function ChatInput({ if (!modelPickerOpen) setReasoningMenuOpen(false); }, [modelPickerOpen]); - useEffect(() => { - if (!effectiveThinkingLevel || effectiveThinkingLevel === 'off') return; - lastEnabledThinkingLevelRef.current = currentSession?.thinkingLevel ?? null; - }, [currentSession?.thinkingLevel, currentSessionKey, effectiveThinkingLevel]); - useEffect(() => { if (workspaceSelectorDisabled) { setWorkspaceMenuOpen(false); @@ -608,34 +602,6 @@ export function ChatInput({ updateSessionThinkingLevel, ]); - const handleToggleThinking = useCallback((enabled: boolean) => { - if (!enabled) { - if (effectiveThinkingLevel !== 'off') { - lastEnabledThinkingLevelRef.current = - currentSession?.thinkingLevel ?? currentSession?.thinkingDefault ?? null; - } - void handleSelectThinkingLevel('off'); - return; - } - const runtimeDefault = currentSession?.thinkingDefault - ?? (canUseThinkingDefaults ? thinkingDefaults?.thinkingDefault : undefined); - void handleSelectThinkingLevel( - lastEnabledThinkingLevelRef.current - ?? (runtimeDefault && runtimeDefault !== 'off' ? runtimeDefault : null) - ?? effortOptions.find((option) => option.id === 'medium')?.id - ?? effortOptions[0]?.id - ?? null, - ); - }, [ - canUseThinkingDefaults, - currentSession?.thinkingDefault, - currentSession?.thinkingLevel, - effectiveThinkingLevel, - effortOptions, - handleSelectThinkingLevel, - thinkingDefaults?.thinkingDefault, - ]); - const handleWorkspaceButtonClick = useCallback(() => { if (workspaceSelectorDisabled) return; setPickerOpen(false); @@ -1303,7 +1269,7 @@ export function ChatInput({ ))} )} - {thinkingOptions.length > 0 && ( + {effortOptions.length > 0 && ( <> {modelOptions.length > 1 &&
}
)} diff --git a/src/stores/acp-chat-session.ts b/src/stores/acp-chat-session.ts index 00153c85..5e34b466 100644 --- a/src/stores/acp-chat-session.ts +++ b/src/stores/acp-chat-session.ts @@ -164,6 +164,9 @@ function errorMessage(error: unknown, fallback: string): string { } function failedOperationMessage(result: AcpChatOperationResult, fallback: string): string { + if (result.errorCode === 'prompt_aborted') { + return i18n.t('chat:acp.promptAborted'); + } return result.error || fallback; } diff --git a/tests/e2e/chat-model-picker.spec.ts b/tests/e2e/chat-model-picker.spec.ts index 2180307a..360de061 100644 --- a/tests/e2e/chat-model-picker.spec.ts +++ b/tests/e2e/chat-model-picker.spec.ts @@ -19,7 +19,6 @@ test.describe('ClawX chat model picker', () => { { 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(); @@ -124,6 +123,13 @@ test.describe('ClawX chat model picker', () => { if (request?.module === 'chat' && request.action === 'loadAcpSession') { return makeResponse(request.id, { success: true, generation: 1 }); } + if (request?.module === 'chat' && request.action === 'sendAcpPrompt') { + return makeResponse(request.id, { + success: false, + error: 'ACP prompt was aborted before producing a response', + errorCode: 'prompt_aborted', + }); + } if (request?.module === 'gateway' && request.action === 'rpc') { const method = typeof body?.method === 'string' ? body.method : ''; const params = body?.params ?? null; @@ -299,15 +305,24 @@ test.describe('ClawX chat model picker', () => { await expect(page.getByTestId('chat-model-picker-button')).toContainText('provider/model-beta (Beta)'); await page.getByTestId('chat-model-picker-button').click(); await page.getByTestId('chat-reasoning-effort-menu-trigger').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'); + await expect(page.getByTestId('chat-reasoning-effort-option-off')).toHaveText('Off'); + await expect(page.getByTestId('chat-reasoning-effort-option-low')).toHaveText('Low'); + await expect(page.getByTestId('chat-reasoning-effort-option-medium')).toHaveText('Medium'); + await expect(page.getByTestId('chat-reasoning-effort-option-high')).toHaveText('High'); + await expect(page.getByTestId('chat-thinking-toggle')).toHaveCount(0); + await page.getByTestId('chat-reasoning-effort-option-high').click(); + await expect(page.getByTestId('chat-model-picker-button')).toContainText('provider/model-beta (Beta) · High'); await page.getByTestId('chat-model-picker-button').click(); await page.getByTestId('chat-reasoning-effort-menu-trigger').click(); - await expect(page.getByTestId('chat-thinking-toggle')).toBeChecked(); - await page.getByTestId('chat-thinking-toggle').click(); + await page.getByTestId('chat-reasoning-effort-option-off').click(); await expect(page.getByTestId('chat-model-picker-button')).toContainText('provider/model-beta (Beta) · Off'); + await page.getByTestId('chat-composer-input').fill('Trigger an aborted model request'); + await page.getByTestId('chat-composer-send').click(); + await expect(page.getByText( + 'The model request ended before producing a response. Please try again.', + )).toBeVisible(); + const requests = await app.evaluate(() => ( (globalThis as typeof globalThis & { __chatModelPickerRequests?: Array<{ path: string; method: string; body: unknown }> }).__chatModelPickerRequests ?? [] )); @@ -319,7 +334,7 @@ test.describe('ClawX chat model picker', () => { expect(requests).toContainEqual({ path: 'gateway:sessions.patch', method: 'RPC', - body: { key: 'agent:main:main', thinkingLevel: 'xhigh' }, + body: { key: 'agent:main:main', thinkingLevel: 'high' }, }); expect(requests).toContainEqual({ path: 'gateway:sessions.patch', diff --git a/tests/unit/acp-chat-service.test.ts b/tests/unit/acp-chat-service.test.ts index 8e1f438b..fb330e0b 100644 --- a/tests/unit/acp-chat-service.test.ts +++ b/tests/unit/acp-chat-service.test.ts @@ -401,6 +401,43 @@ describe('AcpChatService', () => { })).resolves.toEqual({ success: true, generation: 3 }); }); + it('reports an unexpected cancelled prompt as an aborted model request', async () => { + const connection = createConnection(); + connection.prompt.mockResolvedValueOnce({ stopReason: 'cancelled' }); + const { service } = await createService(connection); + + await service.loadSession({ sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo' }); + + await expect(service.sendPrompt({ + sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'please answer', + })).resolves.toEqual({ + success: false, + error: 'ACP prompt was aborted before producing a response', + errorCode: 'prompt_aborted', + }); + }); + + it('keeps an explicitly cancelled prompt successful instead of showing an error', async () => { + const connection = createConnection(); + const prompt = createDeferred<{ stopReason: string }>(); + connection.prompt.mockReturnValueOnce(prompt.promise); + const { service } = await createService(connection); + + await service.loadSession({ sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo' }); + const sendPrompt = service.sendPrompt({ + sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'stop this', + }); + await vi.waitFor(() => expect(connection.prompt).toHaveBeenCalledTimes(1)); + + await expect(service.cancelSession({ sessionKey: 'agent:pi:s1' })).resolves.toEqual({ + success: true, + generation: 1, + }); + prompt.resolve({ stopReason: 'cancelled' }); + + await expect(sendPrompt).resolves.toEqual({ success: true, generation: 1 }); + }); + it('records ACP session load and forwarded update trace entries', async () => { const { clearAcpTraceForTests, getAcpTraceSnapshot } = await import('../../electron/services/acp-trace'); clearAcpTraceForTests(); diff --git a/tests/unit/acp-chat-store.test.ts b/tests/unit/acp-chat-store.test.ts index 86b6541b..bd9f9224 100644 --- a/tests/unit/acp-chat-store.test.ts +++ b/tests/unit/acp-chat-store.test.ts @@ -82,6 +82,7 @@ vi.mock('@/i18n', () => ({ 'chat:imageGeneration.generatedReadyWithMissing': 'Generated image is ready. Some images could not be loaded.', 'chat:imageGeneration.previewUnavailable': 'Image generation completed, but the preview could not be loaded.', 'chat:acp.image': 'Image', + 'chat:acp.promptAborted': 'The model request ended before producing a response. Please try again.', }; return labels[key] ?? key; }, @@ -1377,6 +1378,26 @@ describe('ACP Chat store', () => { expect(useAcpChatSessionStore.getState().cancelling).toBe(false); }); + it('localizes an unexpected aborted model request', async () => { + hostApiMock.sendAcpPrompt.mockResolvedValueOnce({ + success: false, + error: 'ACP prompt was aborted before producing a response', + errorCode: 'prompt_aborted', + }); + const { useAcpChatSessionStore } = await importStore(); + await useAcpChatSessionStore.getState().loadSession({ + sessionKey: 'agent:pi:s1', workspaceRoot: '/repo', cwd: '/repo', + }); + + await expect(useAcpChatSessionStore.getState().sendPrompt({ + sessionKey: 'agent:pi:s1', cwd: '/repo', message: 'hello', + })).resolves.toBe(false); + + expect(useAcpChatSessionStore.getState().error).toBe( + 'The model request ended before producing a response. Please try again.', + ); + }); + it('adds an optimistic user segment immediately before ACP echoes a user update', async () => { const prompt = createDeferred<{ success: boolean; error?: string; generation?: number }>(); hostApiMock.sendAcpPrompt.mockReturnValueOnce(prompt.promise); diff --git a/tests/unit/chat-input.test.tsx b/tests/unit/chat-input.test.tsx index 5a86012e..463cba25 100644 --- a/tests/unit/chat-input.test.tsx +++ b/tests/unit/chat-input.test.tsx @@ -115,10 +115,6 @@ function translate(key: string, vars?: Record): string { return 'Default'; case 'composer.reasoningEffortUpdateFailed': return `Failed to update reasoning effort: ${String(vars?.error ?? '')}`; - case 'composer.thinkingToggleTitle': - return 'Thinking'; - case 'composer.thinkingToggleDescription': - return 'Use reasoning for complex tasks'; case 'composer.clearTarget': return 'Clear target agent'; case 'composer.targetChip': @@ -714,6 +710,7 @@ describe('ChatInput agent targeting', () => { thinkingLevels: [ { id: 'off', label: 'Off' }, { id: 'minimal', label: 'Minimal' }, + { id: 'low', label: 'Low' }, { id: 'medium', label: 'Medium' }, { id: 'high', label: 'High' }, { id: 'xhigh', label: 'Extra High' }, @@ -732,8 +729,12 @@ describe('ChatInput agent targeting', () => { ); expect(screen.queryByTestId('chat-reasoning-effort-option-inherited')).not.toBeInTheDocument(); expect(screen.queryByTestId('chat-reasoning-effort-option-minimal')).not.toBeInTheDocument(); + expect(screen.getByTestId('chat-reasoning-effort-option-off')).toHaveTextContent('Off'); + expect(screen.getByTestId('chat-reasoning-effort-option-low')).toHaveTextContent('Low'); expect(screen.getByTestId('chat-reasoning-effort-option-medium')).toHaveTextContent('Medium'); - expect(screen.getByTestId('chat-reasoning-effort-option-xhigh')).toHaveTextContent('Extra High'); + expect(screen.getByTestId('chat-reasoning-effort-option-high')).toHaveTextContent('High'); + expect(screen.queryByTestId('chat-reasoning-effort-option-xhigh')).not.toBeInTheDocument(); + expect(screen.queryByTestId('chat-thinking-toggle')).not.toBeInTheDocument(); fireEvent.click(screen.getByTestId('chat-reasoning-effort-option-high')); @@ -852,36 +853,12 @@ describe('ChatInput agent targeting', () => { }); }); - it('uses the thinking toggle to turn reasoning off for the current session', async () => { + it('turns reasoning off by selecting Off as an effort level', async () => { configureAgentAndModelPickers(); chatState.sessions = [{ key: chatState.currentSessionKey, thinkingLevel: 'high', thinkingDefault: 'medium', - thinkingLevels: [ - { id: 'off', label: 'Off' }, - { 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-menu-trigger')); - expect(screen.getByTestId('chat-thinking-toggle')).toBeChecked(); - fireEvent.click(screen.getByTestId('chat-thinking-toggle')); - - await waitFor(() => { - expect(chatState.updateSessionThinkingLevel).toHaveBeenCalledWith('agent:main:main', 'off'); - }); - }); - - it('enables thinking at medium when the runtime default is off', async () => { - configureAgentAndModelPickers(); - chatState.sessions = [{ - key: chatState.currentSessionKey, - thinkingDefault: 'off', thinkingLevels: [ { id: 'off', label: 'Off' }, { id: 'low', label: 'Low' }, @@ -894,11 +871,11 @@ describe('ChatInput agent targeting', () => { renderChatInput(); fireEvent.click(screen.getByTestId('chat-model-picker-button')); fireEvent.click(screen.getByTestId('chat-reasoning-effort-menu-trigger')); - expect(screen.getByTestId('chat-thinking-toggle')).not.toBeChecked(); - fireEvent.click(screen.getByTestId('chat-thinking-toggle')); + expect(screen.queryByTestId('chat-thinking-toggle')).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId('chat-reasoning-effort-option-off')); await waitFor(() => { - expect(chatState.updateSessionThinkingLevel).toHaveBeenCalledWith('agent:main:main', 'medium'); + expect(chatState.updateSessionThinkingLevel).toHaveBeenCalledWith('agent:main:main', 'off'); }); }); diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index a17d285e..59a7d295 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -1043,7 +1043,7 @@ describe('syncProviderConfigToOpenClaw', () => { contextWindow: 200000, customField: 'keep-me', compat: { - supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + supportedReasoningEfforts: ['low', 'medium', 'high'], }, }), ]); @@ -1069,7 +1069,7 @@ describe('syncProviderConfigToOpenClaw', () => { input: ['text'], reasoning: true, compat: { - supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + supportedReasoningEfforts: ['low', 'medium', 'high'], }, }), ]); @@ -1115,7 +1115,7 @@ describe('syncProviderConfigToOpenClaw', () => { reasoning: true, compat: { customFlag: true, - supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + supportedReasoningEfforts: ['low', 'medium', 'high'], }, }), ]); diff --git a/tests/unit/provider-runtime-sync.test.ts b/tests/unit/provider-runtime-sync.test.ts index 9a4e9fe8..0e6fe3da 100644 --- a/tests/unit/provider-runtime-sync.test.ts +++ b/tests/unit/provider-runtime-sync.test.ts @@ -179,7 +179,7 @@ describe('provider-runtime-sync config delivery', () => { expect.objectContaining({ id: 'gpt-5.5', reasoning: true, - compat: { supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'] }, + compat: { supportedReasoningEfforts: ['low', 'medium', 'high'] }, }), ], }),