mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ddbba55c3 | ||
|
|
9cadb552d9 | ||
|
|
77c5d45b63 | ||
|
|
47108a7249 | ||
|
|
3a63b10bd5 | ||
|
|
2abe824afd | ||
|
|
c0794bb3b9 | ||
|
|
242a532aa4 | ||
|
|
5621164d1b | ||
|
|
0072082e31 |
+4
-1
@@ -106,6 +106,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。セッション行の削除と画面遷移は完全削除が成功した後にのみ行われ、失敗した場合は会話と確認ダイアログが保持されるため再試行できます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
|
||||
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
|
||||
コンポーザーのモデルメニューでは、Off、Low、Medium、High の4段階の推論強度を専用サブメニューから直接選択できます。カスタムモデルで新しいチャットを作成した場合も、最初の送信前は Gateway が公開する現在の Agent の既定値を使ってこのコントロールを表示し、セッション作成後はセッションメタデータを優先します。選択した強度は現在のセッションに適用され、各 ACP prompt にそのまま渡されます。Off を選ぶと個別の「思考」トグルなしで推論を無効にできます。Provider が応答前にリクエストを予期せず中断した場合、チャットはターンを黙って終了せず、再試行可能なエラーを表示します。
|
||||
|
||||
Chat の右パネルにあるワークスペースとプレビューの各タブでは、Markdown、`.docx`、`.pptx` ファイルを読み取り専用でプレビューできます。Markdown ファイルのプレビューでは、同じシンタックスハイライト、ソフトラップ、コピー操作付きのフェンスコード、CJK 対応の解析、KaTeX 数式を静的レンダリングモードでサポートします。プレビューのヘッダーから選択中のファイルを 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" エラーになる前に圧縮できます。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` プローブへ自動フォールバックします。
|
||||
|
||||
@@ -234,6 +235,8 @@ Chat は Electron Main が所有する ACP stdio bridge を使用します。Ren
|
||||
|
||||
ACP の assistant ターンにはターン全体の所要時間が表示されます。ライブ計時はクライアントが観測した prompt ライフサイクルに従い、アプリ内を移動しても継続します。履歴の所要時間は Electron Main が範囲を限定した OpenClaw transcript のタイムスタンプから算出し、ACP リプレイですでに復元されたターンだけに付与します。
|
||||
|
||||
ライブの assistant 応答中は、ツール実行が会話表示を妨げないようツール表示を既定で非表示にします。応答が完了すると、連続した呼び出しは 1 つの折りたたみグループとして、単独の呼び出しは詳細を折りたたんだ状態で表示され、必要に応じて展開できます。履歴から復元された完了済みツールも既定で折りたたまれます。
|
||||
|
||||
ACP Chat は標準 ACP resource を添付ファイルとして表示します。ユーザーが選択した画像は、ホバー時のオーバーレイにファイル名を表示するサムネイルとして描画され、その他の利用可能な添付カードはファイル名に続いて、淡色で省略可能なソースパスを表示します。現在の OpenClaw ACP adapter が assistant のメディアを省略した場合も、OpenClaw が永続化した正規メディア情報と明示的な assistant の `MEDIA:` ディレクティブを、transcript 専用メタデータを表示せずに添付カードとして復元できます。現在の workspace 外を含む既存のローカルファイル参照は、プレビューまたはオープンのたびに Electron Main で正確な session と generation に対して再検証されます。AI が生成したプレビュー可能なローカル添付ファイル(20 MB 以下の `.docx` と `.pptx` を含む)は、読み取り専用のアプリ内プレビューを主要操作として維持し、対応アプリで開く操作と Finder、エクスプローラー、またはシステムのファイルマネージャーで表示する操作を副次メニューから利用できます。ローカル HTML 添付ファイルでは、そのメニューの先頭項目が右側のプレビューでファイルを開きます。ここでも Office プレビューには同じ制限があります。`.doc` と `.ppt` はシステムアプリで開く形式のままで、DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX のアニメーション、画面切り替え、メディア再生はサポートされません。対応アプリの検出は macOS と Windows のみで利用でき、Linux または検出失敗時には通知せず、ファイルの場所を表示する操作だけに切り替わります。それ以外のローカルファイル(20 MB を超える Office ファイルを含む)はユーザーのクリック後にシステムアプリで開かれます。ユーザーが選択したフォルダー添付も送信後に利用可能なまま保持され、クリックするとシステムのファイルマネージャーで開きます。ClawX はフォルダー内を読み取りまたはプレビューしません。リモートの HTTP/HTTPS 添付ファイルはクリック後に外部で開かれます。正規メディア情報がない通常の文章内の単独またはインラインパスは添付ファイルとして扱われません。
|
||||
|
||||
ACP Chat は、runtime が画像生成メディアを信頼できる構造化メディアとして配信した場合に、生成画像のプレビューも表示できます。信頼できる OpenClaw internal-UI 配信と画像生成タスクに関連付けられた最終返信では、テキストのみの失敗説明を含む元のユーザー向け完了テキストを保持し、汎用の画像キャプションへ置き換えません。OpenClaw の履歴リプレイ中は、同じセッションで画像生成タスク開始が記録されている場合に限り、assistant の画像 `MEDIA:` マーカーがインライン画像表示へ昇格されます。ClawX は Renderer から任意にファイルシステムへアクセスするのではなく、Electron Main のホストメディア処理を通じてプレビューを読み込みます。標準 ACP の画像と resource コンテンツは引き続き推奨パスであり、そのまま描画されます。
|
||||
|
||||
@@ -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. 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 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 and is forwarded unchanged with each ACP prompt; 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.
|
||||
|
||||
@@ -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. 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.
|
||||
|
||||
@@ -234,6 +235,8 @@ An unfinished ACP response keeps streaming when you open another conversation or
|
||||
|
||||
ACP assistant turns show whole-turn duration. Live timing follows the client-observed prompt lifecycle and survives in-app navigation; historical timing is derived in Electron Main from bounded OpenClaw transcript timestamps and only annotates a turn already restored by ACP replay.
|
||||
|
||||
While a live assistant response is running, its tool activity stays hidden so tool execution does not interrupt the conversation display. When the response finishes, every tool is shown collapsed by default—consecutive calls as one collapsed group and a single call with collapsed details—and can be expanded on demand. Completed tools restored from history also start collapsed.
|
||||
|
||||
ACP Chat renders standard ACP resources as attachments. User-selected images appear as thumbnails with a filename hover overlay, while other available attachment cards show the filename and a muted, truncating source path. When the current OpenClaw ACP adapter omits assistant media, canonical persisted OpenClaw media facts and explicit assistant `MEDIA:` directives can also be recovered as attachment cards without displaying transcript-only metadata. Existing local file references, including paths outside the active workspace, are revalidated in Electron Main for the exact session and generation before every preview or open. Previewable local attachments produced by the AI, including `.docx` and `.pptx` files within the 20 MB inline-preview limit, keep their primary read-only in-app preview action and provide a secondary menu for opening with compatible applications or revealing the file in Finder, File Explorer, or the system file manager. For local HTML attachments, that menu starts with an action that opens the file in the right-side Preview tab. The same Office limitations apply here: `.doc` and `.ppt` remain system-open formats, DOCX pagination may differ from Microsoft Word, and PPTX animations, transitions, and media playback are unsupported. Compatible-application discovery is available only on macOS and Windows and silently degrades to reveal-only behavior on Linux or when discovery fails. Other local files, including Office files larger than 20 MB, open in the system application after a user click. User-selected folder attachments also remain available after send and open in the system file manager; ClawX does not read or preview their contents. Remote HTTP and HTTPS attachments open externally after a user click. Bare or inline prose paths without canonical media facts are not treated as attachments.
|
||||
|
||||
ACP Chat can also display generated image previews when image-generation media is delivered by the runtime as trusted structured media. Trusted OpenClaw internal-UI deliveries and task-correlated final replies preserve the original user-facing completion text, including text-only failure explanations, rather than replacing it with a generic image caption. During historical OpenClaw replay, assistant image `MEDIA:` markers are promoted to the inline image experience only when they follow a recorded image-generation task start for that session. ClawX loads previews through host media handling in Electron Main, not arbitrary Renderer filesystem access. Standard ACP image and resource content remains the preferred path and renders directly.
|
||||
|
||||
+4
-1
@@ -107,6 +107,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。只有永久删除成功后,会话行才会移除且页面才会跳转;删除失败时会保留会话与确认框,方便重试。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。
|
||||
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
|
||||
输入框的模型菜单会在独立子菜单中提供四个可直接选择的推理强度:Off、Low、Medium 和 High。使用自定义模型新建对话时,首次发送前也会根据 Gateway 提供的当前 Agent 默认值显示该控件;会话创建后则以会话元数据为准。所选强度仅对当前会话持续生效,并会原样随每次 ACP prompt 传递;选择 Off 即可关闭推理,不再提供单独的“思考”开关。如果 Provider 在回复前意外中止请求,聊天界面会显示可重试错误,而不是静默结束本轮。
|
||||
|
||||
Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 Markdown、`.docx` 和 `.pptx` 文件。Markdown 文件预览以静态渲染模式提供相同的围栏代码语法高亮、软换行与复制操作、面向中日韩文本的解析和 KaTeX 数学公式支持。预览栏顶部可将当前文件展开至 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。
|
||||
如果你通过 **自定义(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" 报错。当你没有配置 compaction 时,ClawX 会默认写入 `agents.defaults.compaction.mode = "safeguard"` 和 `reserveTokensFloor = 50000`;你手动配置过的模型行或压缩配置永远不会被修改(仅可能回填缺失的 `reserveTokensFloor`)。
|
||||
自定义 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` 探测。
|
||||
|
||||
@@ -235,6 +236,8 @@ Chat 使用由 Electron Main 持有的 ACP stdio bridge。Renderer 接收类型
|
||||
|
||||
ACP assistant 回合会显示整轮耗时。Live 计时跟随客户端观测到的 prompt 生命周期,并在应用内导航后保持连续;历史耗时由 Electron Main 根据有界的 OpenClaw transcript 时间戳计算,而且只能标注 ACP 回放已经恢复出的回合。
|
||||
|
||||
实时 assistant 回复进行时,工具活动默认隐藏,避免工具执行过程打断对话显示。回复结束后,所有工具都会以折叠状态显示——连续调用合并为一个折叠分组,单个调用则折叠其详情——并可按需展开;从历史记录恢复的已完成工具也默认折叠。
|
||||
|
||||
ACP Chat 会将标准 ACP resource 渲染为附件。用户选择的图片会显示为缩略图,并在悬停蒙层中显示文件名;其它可用的附件卡片会显示文件名,以及灰色、可截断的来源路径。当前 OpenClaw ACP adapter 遗漏 assistant 媒体时,OpenClaw 持久化的规范媒体事实和显式 assistant `MEDIA:` 指令也可恢复为附件卡片,且不会显示仅用于 transcript 的元数据。现有本地文件引用(包括当前 workspace 外的路径)在每次预览或打开前,都会由 Electron Main 按精确的 session 和 generation 重新验证。AI 生成且可预览的本地附件(包括不超过 20 MB 的 `.docx` 和 `.pptx` 文件)会保留主要的只读应用内预览操作,并提供次级菜单,可通过兼容应用打开,或在 Finder、文件资源管理器或系统文件管理器中显示。对于本地 HTML 附件,该菜单第一项会在右侧预览中打开文件。Office 预览在此处也有相同限制:`.doc` 和 `.ppt` 仍通过系统应用打开,DOCX 的分页效果可能与 Microsoft Word 不同,PPTX 的动画、切换效果和媒体播放不受支持。兼容应用发现仅在 macOS 和 Windows 上可用;在 Linux 上或发现失败时,会静默降级为仅显示文件位置。其它本地文件(包括超过 20 MB 的 Office 文件)会在用户点击后通过系统应用打开。用户选择的文件夹附件在发送后也会保持可用,点击后交给系统文件管理器打开;ClawX 不会读取或预览其中内容。远程 HTTP 和 HTTPS 附件会在用户点击后从外部打开。没有规范媒体事实佐证的普通文本裸路径或行内路径不会被当作附件。
|
||||
|
||||
ACP Chat 也可在 runtime 以可信结构化媒体投递图像生成结果时显示生成图片预览。对于可信的 OpenClaw internal-UI 投递和与生图任务关联的最终回复,ClawX 会保留原始的用户可见完成文案,包括只有文本的失败说明,而不会统一替换成通用图片文案。历史 OpenClaw 回放中,assistant 的图片 `MEDIA:` 标记只有在同一会话已记录图像生成任务启动后才会进入内联图片体验。ClawX 通过 Electron Main 的主机媒体处理加载预览,而不是让 Renderer 任意访问文件系统。标准 ACP 图片和 resource 内容仍是首选路径,并会直接渲染。
|
||||
|
||||
@@ -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<void> | null = null;
|
||||
private activeLoadBatch: AcpSessionLoadBatch | null = null;
|
||||
private readonly livePrompts = new Map<string, AcpLivePromptContext>();
|
||||
private readonly clientCancelledPromptSessions = new Set<string>();
|
||||
private permissionSeq = 0;
|
||||
private readonly permissionWaiters = new Map<string, PermissionWaiter>();
|
||||
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,19 +402,35 @@ 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
|
||||
// ClawX correlation metadata in the protocol extension envelope.
|
||||
// OpenClaw must receive slash commands without its textual cwd prefix
|
||||
// so the Gateway can classify and fold command replies into chat final.
|
||||
_meta: { sessionKey: payload.sessionKey, prefixCwd: !isSlashCommand, messageId },
|
||||
_meta: {
|
||||
sessionKey: payload.sessionKey,
|
||||
prefixCwd: !isSlashCommand,
|
||||
messageId,
|
||||
...(payload.thinkingLevel ? { thinkingLevel: payload.thinkingLevel } : {}),
|
||||
},
|
||||
});
|
||||
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 +445,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 +454,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 +465,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 +620,7 @@ export class AcpChatService {
|
||||
this.historicalGeneration = null;
|
||||
this.permissionsEnabled = false;
|
||||
this.livePrompts.clear();
|
||||
this.clientCancelledPromptSessions.clear();
|
||||
}
|
||||
|
||||
private emitSessionUpdate(notification: SessionNotification): void {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getProviderSecret } from '../secrets/secret-store';
|
||||
import type { ProviderConfig } from '../../utils/secure-storage';
|
||||
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
|
||||
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
|
||||
import { CUSTOM_PROVIDER_DEFAULT_REASONING_EFFORTS } from '../../shared/providers/types';
|
||||
import {
|
||||
ensureAnthropicMessagesModelMaxTokens,
|
||||
ensureOpenClawProviderAgentRuntimePins,
|
||||
@@ -307,6 +308,16 @@ async function syncRuntimeProviderConfig(
|
||||
});
|
||||
}
|
||||
|
||||
function customProviderModelEntry(_config: ProviderConfig, modelId: string) {
|
||||
return {
|
||||
...piAiModelsJsonModelEntry(modelId),
|
||||
reasoning: true,
|
||||
compat: {
|
||||
supportedReasoningEfforts: [...CUSTOM_PROVIDER_DEFAULT_REASONING_EFFORTS],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function syncCustomProviderAgentModel(
|
||||
config: ProviderConfig,
|
||||
runtimeProviderKey: string,
|
||||
@@ -325,7 +336,7 @@ async function syncCustomProviderAgentModel(
|
||||
await updateAgentModelProvider(runtimeProviderKey, {
|
||||
baseUrl: normalizeProviderBaseUrl(config, config.baseUrl, config.apiProtocol || 'openai-completions'),
|
||||
api: config.apiProtocol || 'openai-completions',
|
||||
models: modelId ? [piAiModelsJsonModelEntry(modelId)] : [],
|
||||
models: modelId ? [customProviderModelEntry(config, modelId)] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
@@ -453,7 +464,9 @@ async function buildAgentModelProviderEntry(
|
||||
return {
|
||||
baseUrl,
|
||||
api,
|
||||
models: [piAiModelsJsonModelEntry(modelId)],
|
||||
models: [config.type === 'custom'
|
||||
? customProviderModelEntry(config, modelId)
|
||||
: piAiModelsJsonModelEntry(modelId)],
|
||||
apiKey,
|
||||
authHeader,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ProviderAccount,
|
||||
ProviderConfig,
|
||||
ProviderDefinition,
|
||||
ProviderReasoningEffort,
|
||||
ProviderType,
|
||||
} from '../../shared/providers/types';
|
||||
import { BUILTIN_PROVIDER_TYPES } from '../../shared/providers/types';
|
||||
@@ -105,6 +106,50 @@ function mergeSyncedProviderMetadata(
|
||||
return Object.keys(next).length > 0 ? next : undefined;
|
||||
}
|
||||
|
||||
const PROVIDER_REASONING_EFFORTS = new Set<ProviderReasoningEffort>([
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
]);
|
||||
|
||||
function readCustomProviderReasoning(
|
||||
providerKey: string,
|
||||
entry: Record<string, unknown>,
|
||||
modelRef: string | undefined,
|
||||
): { reasoningEnabled: boolean; reasoningEfforts: ProviderReasoningEffort[] } {
|
||||
const modelId = modelRef?.startsWith(`${providerKey}/`)
|
||||
? modelRef.slice(providerKey.length + 1)
|
||||
: modelRef;
|
||||
const rows = Array.isArray(entry.models) ? entry.models : [];
|
||||
const row = rows.find((candidate) => (
|
||||
candidate
|
||||
&& typeof candidate === 'object'
|
||||
&& !Array.isArray(candidate)
|
||||
&& (candidate as Record<string, unknown>).id === modelId
|
||||
));
|
||||
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
||||
return { reasoningEnabled: false, reasoningEfforts: [] };
|
||||
}
|
||||
|
||||
const record = row as Record<string, unknown>;
|
||||
const compat = record.compat && typeof record.compat === 'object' && !Array.isArray(record.compat)
|
||||
? record.compat as Record<string, unknown>
|
||||
: undefined;
|
||||
const reasoningEfforts = Array.isArray(compat?.supportedReasoningEfforts)
|
||||
? compat.supportedReasoningEfforts.filter(
|
||||
(effort): effort is ProviderReasoningEffort => (
|
||||
typeof effort === 'string'
|
||||
&& PROVIDER_REASONING_EFFORTS.has(effort as ProviderReasoningEffort)
|
||||
),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
reasoningEnabled: record.reasoning === true,
|
||||
reasoningEfforts,
|
||||
};
|
||||
}
|
||||
|
||||
export class ProviderService {
|
||||
async listVendors(): Promise<ProviderDefinition[]> {
|
||||
return PROVIDER_DEFINITIONS;
|
||||
@@ -199,13 +244,23 @@ export class ProviderService {
|
||||
if (syncedAccount) {
|
||||
const nextMetadata = mergeSyncedProviderMetadata(kept.metadata, syncedAccount.metadata);
|
||||
const shouldSyncSelectedModel = defaultModel?.startsWith(`${key}/`) ?? false;
|
||||
const nextModel = shouldSyncSelectedModel ? syncedAccount.model : kept.model;
|
||||
const nextModel = shouldSyncSelectedModel ? syncedAccount.model : (kept.model ?? syncedAccount.model);
|
||||
const reasoning = kept.vendorId === 'custom'
|
||||
? readCustomProviderReasoning(key, entry, nextModel)
|
||||
: {
|
||||
reasoningEnabled: kept.reasoningEnabled,
|
||||
reasoningEfforts: kept.reasoningEfforts,
|
||||
};
|
||||
const shouldSyncModelState = kept.model !== nextModel
|
||||
|| kept.reasoningEnabled !== reasoning.reasoningEnabled
|
||||
|| JSON.stringify(kept.reasoningEfforts ?? []) !== JSON.stringify(reasoning.reasoningEfforts ?? [])
|
||||
|| !providerMetadataEquals(kept.metadata, nextMetadata);
|
||||
if (shouldSyncModelState) {
|
||||
kept = {
|
||||
...kept,
|
||||
model: nextModel,
|
||||
reasoningEnabled: reasoning.reasoningEnabled,
|
||||
reasoningEfforts: reasoning.reasoningEfforts,
|
||||
metadata: nextMetadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -314,7 +369,12 @@ export class ProviderService {
|
||||
model = defaultModel;
|
||||
} else if (definition?.defaultModelId) {
|
||||
model = definition.defaultModelId;
|
||||
} else if (vendorId === 'custom' && customModels?.[0]) {
|
||||
model = customModels[0];
|
||||
}
|
||||
const reasoning = vendorId === 'custom'
|
||||
? readCustomProviderReasoning(key, entry, model)
|
||||
: undefined;
|
||||
|
||||
const account: ProviderAccount = {
|
||||
id: key,
|
||||
@@ -327,6 +387,8 @@ export class ProviderService {
|
||||
? (entry.headers as Record<string, string>)
|
||||
: undefined),
|
||||
model,
|
||||
reasoningEnabled: reasoning?.reasoningEnabled,
|
||||
reasoningEfforts: reasoning?.reasoningEfforts,
|
||||
metadata: customModels && customModels.length > 0
|
||||
? { customModels }
|
||||
: undefined,
|
||||
|
||||
@@ -31,6 +31,8 @@ export function providerConfigToAccount(
|
||||
: getProviderDefinition(config.type)?.providerConfig?.api),
|
||||
headers: config.headers,
|
||||
model: config.model,
|
||||
reasoningEnabled: config.reasoningEnabled,
|
||||
reasoningEfforts: config.reasoningEfforts,
|
||||
fallbackModels: config.fallbackModels,
|
||||
fallbackAccountIds: config.fallbackProviderIds,
|
||||
enabled: config.enabled,
|
||||
@@ -49,6 +51,8 @@ export function providerAccountToConfig(account: ProviderAccount): ProviderConfi
|
||||
apiProtocol: account.apiProtocol,
|
||||
headers: account.headers,
|
||||
model: account.model,
|
||||
reasoningEnabled: account.reasoningEnabled,
|
||||
reasoningEfforts: account.reasoningEfforts,
|
||||
fallbackModels: account.fallbackModels,
|
||||
fallbackProviderIds: account.fallbackAccountIds,
|
||||
enabled: account.enabled,
|
||||
|
||||
@@ -97,6 +97,14 @@ export function assertValidApiProtocol(
|
||||
}
|
||||
|
||||
export type ProviderProtocol = OpenClawApiProtocol;
|
||||
export type ProviderReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
|
||||
|
||||
/** Fixed effort ladder written for every custom-provider primary model. */
|
||||
export const CUSTOM_PROVIDER_DEFAULT_REASONING_EFFORTS: ProviderReasoningEffort[] = [
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
];
|
||||
|
||||
export type ProviderAuthMode =
|
||||
| 'api_key'
|
||||
@@ -118,6 +126,8 @@ export interface ProviderConfig {
|
||||
apiProtocol?: ProviderProtocol;
|
||||
headers?: Record<string, string>;
|
||||
model?: string;
|
||||
reasoningEnabled?: boolean;
|
||||
reasoningEfforts?: ProviderReasoningEffort[];
|
||||
fallbackModels?: string[];
|
||||
fallbackProviderIds?: string[];
|
||||
enabled: boolean;
|
||||
@@ -182,6 +192,8 @@ export interface ProviderAccount {
|
||||
apiProtocol?: ProviderProtocol;
|
||||
headers?: Record<string, string>;
|
||||
model?: string;
|
||||
reasoningEnabled?: boolean;
|
||||
reasoningEfforts?: ProviderReasoningEffort[];
|
||||
fallbackModels?: string[];
|
||||
fallbackAccountIds?: string[];
|
||||
enabled: boolean;
|
||||
|
||||
@@ -42,9 +42,13 @@ import { PORTS } from './config';
|
||||
import { getSetting, setSetting } from './store';
|
||||
import {
|
||||
assertValidApiProtocol,
|
||||
CUSTOM_PROVIDER_DEFAULT_REASONING_EFFORTS,
|
||||
normalizeOpenClawApiProtocol,
|
||||
} from '../shared/providers/types';
|
||||
import { inferCustomModelContextWindow, inferCustomModelInputModalities } from '../shared/providers/model-capabilities';
|
||||
import {
|
||||
inferCustomModelContextWindow,
|
||||
inferCustomModelInputModalities,
|
||||
} from '../shared/providers/model-capabilities';
|
||||
import {
|
||||
CLAWX_OPENAI_IMAGE_DEFAULT_MODEL,
|
||||
CLAWX_OPENAI_IMAGE_PROVIDER_KEY,
|
||||
@@ -909,35 +913,34 @@ 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.
|
||||
* Self-heal helper: walk `models.providers.custom-*` entries and fill inferred
|
||||
* context-window 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return backfilled;
|
||||
return [...backfilled];
|
||||
}
|
||||
|
||||
// ── Exported Functions (all async) ───────────────────────────────
|
||||
@@ -1896,6 +1899,21 @@ function applyOpenClawProviderAgentRuntimePinsToConfig(config: Record<string, un
|
||||
return pinned;
|
||||
}
|
||||
|
||||
function applyCustomPrimaryModelReasoning(
|
||||
models: Array<Record<string, unknown>>,
|
||||
primaryModelId: string | undefined,
|
||||
): void {
|
||||
if (!primaryModelId) return;
|
||||
|
||||
const row = models.find((model) => model.id === primaryModelId);
|
||||
if (!row) return;
|
||||
|
||||
row.reasoning = true;
|
||||
const compat = isPlainRecord(row.compat) ? { ...row.compat } : {};
|
||||
compat.supportedReasoningEfforts = [...CUSTOM_PROVIDER_DEFAULT_REASONING_EFFORTS];
|
||||
row.compat = compat;
|
||||
}
|
||||
|
||||
function upsertOpenClawProviderEntry(
|
||||
config: Record<string, unknown>,
|
||||
provider: string,
|
||||
@@ -1933,6 +1951,9 @@ function upsertOpenClawProviderEntry(
|
||||
: {}),
|
||||
}));
|
||||
let mergedModels = mergeProviderModels(registryModels, existingModels, runtimeModels);
|
||||
if (provider.startsWith('custom-')) {
|
||||
applyCustomPrimaryModelReasoning(mergedModels, options.modelIds?.[0]);
|
||||
}
|
||||
if (options.api === 'anthropic-messages') {
|
||||
mergedModels = mergedModels.map((model) => ensureAnthropicMessagesModelEntry(model, provider, existingProvider));
|
||||
}
|
||||
@@ -2609,14 +2630,14 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
let pinnedProviderRuntimes: string[] = [];
|
||||
let compactionLog: string | undefined;
|
||||
let memorySearchDefaultResult: 'migrated' | 'seeded' | 'unchanged' = 'unchanged';
|
||||
let backfilledContextWindows: string[] = [];
|
||||
let backfilledModelCapabilities: string[] = [];
|
||||
|
||||
const changed = await mutateOpenClawConfig((config) => {
|
||||
let modified = true;
|
||||
pinnedProviderRuntimes = [];
|
||||
compactionLog = undefined;
|
||||
memorySearchDefaultResult = 'unchanged';
|
||||
backfilledContextWindows = [];
|
||||
backfilledModelCapabilities = [];
|
||||
|
||||
// ── Gateway token + controlUi ──
|
||||
const gateway = (
|
||||
@@ -2728,9 +2749,9 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// ── Custom provider contextWindow backfill ──
|
||||
backfilledContextWindows = backfillCustomProviderModelContextWindows(config);
|
||||
if (backfilledContextWindows.length > 0) {
|
||||
// ── Custom provider model-capability backfill ──
|
||||
backfilledModelCapabilities = backfillCustomProviderModelCapabilities(config);
|
||||
if (backfilledModelCapabilities.length > 0) {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
@@ -2750,8 +2771,8 @@ export async function batchSyncConfigFields(token: string): Promise<void> {
|
||||
+ 'agents.defaults.memorySearch to FTS-only mode',
|
||||
);
|
||||
}
|
||||
if (backfilledContextWindows.length > 0) {
|
||||
console.log(`[batch-sync] Backfilled contextWindow for custom provider models: ${backfilledContextWindows.join(', ')}`);
|
||||
if (backfilledModelCapabilities.length > 0) {
|
||||
console.log(`[batch-sync] Backfilled custom provider model capabilities: ${backfilledModelCapabilities.join(', ')}`);
|
||||
}
|
||||
if (changed) {
|
||||
console.log('Synced gateway token, browser config, web_fetch SSRF policy, and session idle to openclaw.json');
|
||||
@@ -2812,8 +2833,19 @@ async function updateModelsJsonProviderEntriesForAgents(
|
||||
const mergedModels = (entry.models ?? []).map((m) => {
|
||||
const prev = existingModels.find((e) => e.id === m.id);
|
||||
const base = prev ? { ...prev, id: m.id, name: m.name } : { ...m };
|
||||
if (typeof m.reasoning === 'boolean') {
|
||||
base.reasoning = m.reasoning;
|
||||
const compat = isPlainRecord(base.compat) ? { ...base.compat } : {};
|
||||
if (isPlainRecord(m.compat) && Array.isArray(m.compat.supportedReasoningEfforts)) {
|
||||
compat.supportedReasoningEfforts = [...m.compat.supportedReasoningEfforts];
|
||||
} else if (!m.reasoning) {
|
||||
delete compat.supportedReasoningEfforts;
|
||||
}
|
||||
if (Object.keys(compat).length > 0) base.compat = compat;
|
||||
else delete base.compat;
|
||||
}
|
||||
// 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'
|
||||
|
||||
@@ -72,7 +72,7 @@ The protocol timeline remains flat. `src/lib/acp/timeline-groups.ts` derives dis
|
||||
- Assistant-side items before the first user item still form a visible assistant turn.
|
||||
- Grouping never infers ownership from `messageId`, `toolCallId`, `_meta`, or synthetic persisted turn ids.
|
||||
|
||||
An assistant turn has one identity column and one copy action. Copy includes textual assistant segments and excludes tool output. Its footer may show localized whole-turn metadata: live duration runs from optimistic send until prompt settlement, while historical duration runs from the matched transcript user record to the latest assistant or tool-result record before the next real user. Missing or ambiguous timing stays hidden. Tool cards render inline in original order, preserve preformatted whitespace, auto-collapse one second after live completion, respect manual override, and start collapsed when historical and completed.
|
||||
An assistant turn has one identity column and one copy action. Copy includes textual assistant segments and excludes tool output. Its footer may show localized whole-turn metadata: live duration runs from optimistic send until prompt settlement, while historical duration runs from the matched transcript user record to the latest assistant or tool-result record before the next real user. Missing or ambiguous timing stays hidden. Tool items retain their original timeline order and preserve preformatted whitespace, but a live assistant turn hides its tool presentation while the prompt is still running. When the prompt settles, consecutive tool runs appear as collapsed groups and a single tool appears with its details collapsed; users can then expand the group or card. Historical completed tools also start collapsed. This is presentation-only state: hidden or collapsed tools remain in the in-memory timeline, and tool events without live turn timing retain the standalone fallback behavior.
|
||||
|
||||
## Attachments
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ appliesTo:
|
||||
- gateway-backend-communication
|
||||
---
|
||||
|
||||
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.
|
||||
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. Renderer presentation may use live whole-turn timing to hide tool UI while a prompt is running and reveal every tool collapsed when that prompt settles, but it must not remove, reorder, or delay reduction of the underlying tool items. Manual post-settlement expansion remains UI-only state, and historical completed tools also start collapsed. 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 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. Once selected, the exact explicit session level must also be forwarded in ACP prompt metadata; a session without an explicit override must omit that prompt field and continue inheriting from Gateway. 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.
|
||||
|
||||
|
||||
@@ -23,3 +23,9 @@ 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.
|
||||
|
||||
Custom-provider primary model rows receive `reasoning: true` and the fixed
|
||||
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.
|
||||
|
||||
@@ -59,7 +59,7 @@ requiredRules:
|
||||
- docs-sync
|
||||
---
|
||||
|
||||
ACP Chat covers session load, prompt, cancel, permission, replay, timeline reduction, assistant-turn presentation and whole-turn duration, standard ACP attachments, bounded generated-media and OpenClaw MEDIA compatibility, and Chat-specific diagnostics. The user-visible attachment flow includes attachment-scoped preview, system open, selected-application open, reveal actions, and a first-position built-in Preview action for eligible local HTML, with platform discovery limited to macOS and Windows. Authorized local DOCX/PPTX attachments within the Office limit use scoped Preview; remote, legacy, and over-limit Office attachments retain scoped system/external-open behavior. User-selected directories remain system-open-only targets: Main may open the directory after session-scoped revalidation, but directory contents are not read, enumerated, previewed, or exposed to Open With.
|
||||
ACP Chat covers session load, prompt, cancel, permission, replay, timeline reduction, assistant-turn presentation and whole-turn duration, live tool visibility and completed-turn collapse behavior, standard ACP attachments, bounded generated-media and OpenClaw MEDIA compatibility, and Chat-specific diagnostics. The user-visible attachment flow includes attachment-scoped preview, system open, selected-application open, reveal actions, and a first-position built-in Preview action for eligible local HTML, with platform discovery limited to macOS and Windows. Authorized local DOCX/PPTX attachments within the Office limit use scoped Preview; remote, legacy, and over-limit Office attachments retain scoped system/external-open behavior. User-selected directories remain system-open-only targets: Main may open the directory after session-scoped revalidation, but directory contents are not read, enumerated, previewed, or exposed to Open With.
|
||||
|
||||
Main owns ACP transport, routing, transcript retrieval and timing extraction, workspace grants, and session/generation-scoped attachment authorization. Renderer owns the in-memory timeline, bounded compatibility and timing alignment, attachment presentation, and display grouping, including user-image thumbnails and user-selected source-path labels. ACP replay remains authoritative for historical turns and content; transcript-derived timing may only annotate an unambiguously matched ACP turn. Standard ACP content remains preferred over compatibility projections, and incidental tool paths never enter the attachment pipeline.
|
||||
|
||||
|
||||
@@ -98,4 +98,6 @@ 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. The selected explicit override is also copied unchanged into the typed ACP prompt metadata so execution cannot silently use a different prompt-level effort; inherited sessions omit that field. 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.
|
||||
|
||||
Gateway WebSocket heartbeat misses are diagnostic availability signals for the first nine consecutive misses and must not interrupt long-running work during that window. A pong or any incoming Gateway message resets the sequence. On the tenth consecutive miss, Main may request the guarded Gateway restart path when auto-recovery is enabled and lifecycle state is still running; the heartbeat callback must not directly terminate the socket or process. Authoritative process-exit and socket-close signals retain their existing automatic lifecycle paths.
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: acp-tool-card-visibility
|
||||
title: Hide live ACP tool activity and collapse completed turns
|
||||
scenario: acp-chat-experience
|
||||
taskType: renderer-ui
|
||||
intent: Keep tool execution out of the live conversation display, then reveal every tool collapsed after the assistant response settles.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/acp-tool-card-visibility.md
|
||||
- harness/specs/scenarios/acp-chat-experience.md
|
||||
- harness/specs/rules/acp-chat-state-and-history.md
|
||||
- harness/reference/acp-chat.md
|
||||
- src/pages/Chat/AcpAssistantTurn.tsx
|
||||
- src/pages/Chat/AcpToolCallCard.tsx
|
||||
- src/pages/Chat/AcpToolCallsGroup.tsx
|
||||
- tests/unit/acp-chat-components.test.tsx
|
||||
- tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- README.md
|
||||
- README.zh-CN.md
|
||||
- README.ja-JP.md
|
||||
expectedUserBehavior:
|
||||
- Tool UI belonging to a live assistant turn is not shown while that turn is running.
|
||||
- When the assistant response settles, consecutive tool calls appear as a collapsed group and a single tool appears with collapsed details.
|
||||
- Users can expand completed tool groups or individual cards after they appear.
|
||||
- Historical completed tools remain collapsed by default.
|
||||
- Tool events without live whole-turn timing retain the existing standalone rendering fallback.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- e2e
|
||||
requiredRules:
|
||||
- acp-chat-state-and-history
|
||||
- ui-i18n-design-tokens
|
||||
- electron-rendering-performance
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- pnpm exec vitest run tests/unit/acp-chat-components.test.tsx
|
||||
- pnpm run typecheck
|
||||
- pnpm run lint:check
|
||||
- pnpm run build:vite
|
||||
- pnpm exec playwright test tests/e2e/chat-acp-inline-timeline.spec.ts
|
||||
- pnpm harness validate --spec harness/specs/tasks/acp-tool-card-visibility.md
|
||||
- pnpm harness run --spec harness/specs/tasks/acp-tool-card-visibility.md
|
||||
acceptance:
|
||||
- Live visibility is derived from the existing running whole-turn timing for the owning user message; no transport, reducer, or persisted history state is added.
|
||||
- Hiding live tool UI does not remove, reorder, or delay its timeline item, output, attachment extraction, or file-activity projection.
|
||||
- A settled live turn mounts consecutive tool calls as one collapsed group and mounts a single tool with collapsed details immediately.
|
||||
- Manual expansion affects only the selected completed group or card.
|
||||
- Historical completed tools preserve their existing collapsed-first behavior.
|
||||
- Focused unit, Electron E2E, typecheck, lint, Vite build, harness validation, and synchronized README checks pass.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This task changes only ACP Chat presentation. The existing live whole-turn timing determines whether a turn is still running; the flat timeline remains authoritative and continues to reduce every tool update immediately.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Hiding permission prompts, thoughts, plans, attachments, or file-activity controls.
|
||||
- Changing ACP transport, event reduction, tool status, or history replay.
|
||||
- Adding a global user preference for tool visibility.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
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/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
|
||||
- src/pages/Chat/ChatInput.tsx
|
||||
- src/pages/Chat/index.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/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
|
||||
- tests/e2e/chat-model-picker.spec.ts
|
||||
- tests/e2e/provider-lifecycle.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 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 compact dedicated submenu without a separate Thinking toggle.
|
||||
- Selecting Off, Low, Medium, or High persists that explicit current-session override through Gateway sessions.patch.
|
||||
- Each ACP prompt carries the same explicit reasoning effort selected for its current session instead of falling back to a different prompt-level effort.
|
||||
- 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
|
||||
- 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/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
|
||||
- tests/e2e/chat-model-picker.spec.ts
|
||||
- tests/e2e/provider-lifecycle.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.
|
||||
- A local draft falls back only to agent-scoped sessions.list defaults, and a persisted session row always takes precedence.
|
||||
- Custom-provider primary models are synced with a fixed OpenClaw reasoning ladder; provider settings do not expose enable-reasoning controls.
|
||||
- Explicit off is distinct from a cleared override.
|
||||
- The reasoning-effort submenu stays narrower than the parent model menu while preserving readable wrapped guidance.
|
||||
- An explicit user-selected session level is forwarded unchanged in ACP prompt metadata; an inherited session adds no prompt-level 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`.
|
||||
- Forward the explicit current-session selection through ACP prompt metadata so prompt execution uses that exact level.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- Adding an independently selectable per-message or per-agent thinking default.
|
||||
- Maintaining a model/provider capability table in ClawX.
|
||||
- Changing OpenClaw reasoning semantics.
|
||||
@@ -29,6 +29,8 @@ export type AcpChatPromptPayload = AcpSessionKeyPayload & {
|
||||
message?: string;
|
||||
media?: AcpPromptMediaItem[];
|
||||
messageId?: string;
|
||||
/** Explicit current-session reasoning effort selected by the user. */
|
||||
thinkingLevel?: string;
|
||||
};
|
||||
|
||||
export type AcpChatCancelPayload = AcpSessionKeyPayload;
|
||||
@@ -38,9 +40,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;
|
||||
|
||||
@@ -81,6 +81,20 @@ export interface ContentBlock {
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
export interface ThinkingLevelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Agent-scoped defaults advertised by Gateway sessions.list. */
|
||||
export interface ChatThinkingDefaults {
|
||||
agentId: string;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
thinkingLevels?: ThinkingLevelOption[];
|
||||
thinkingDefault?: string;
|
||||
}
|
||||
|
||||
/** Session from sessions.list */
|
||||
export interface ChatSession {
|
||||
key: string;
|
||||
@@ -90,7 +104,13 @@ 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;
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
updatedAt?: number;
|
||||
status?: string;
|
||||
@@ -138,6 +158,11 @@ export interface ChatState {
|
||||
/** Last message timestamp (ms) per session key, used for sorting */
|
||||
sessionLastActivity: Record<string, number>;
|
||||
|
||||
/** Agent-scoped fallback metadata for a local draft not yet listed by Gateway. */
|
||||
thinkingDefaults: ChatThinkingDefaults | null;
|
||||
/** Session currently persisting a reasoning-effort override. */
|
||||
thinkingLevelUpdatingSessionKey: string | null;
|
||||
|
||||
loadSessions: (options?: LoadSessionsOptions) => Promise<void>;
|
||||
handleSessionsChanged: (payload: GatewaySessionsChangedPayload) => void;
|
||||
switchSession: (key: string) => void;
|
||||
@@ -147,6 +172,7 @@ export interface ChatState {
|
||||
deleteSession: (key: string) => Promise<DeleteSessionResult>;
|
||||
deleteSessions: (keys: string[]) => Promise<DeleteSessionsResult>;
|
||||
renameSession: (key: string, label: string) => Promise<void>;
|
||||
updateSessionThinkingLevel: (key: string, level: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export const DEFAULT_CANONICAL_PREFIX = 'agent:main';
|
||||
|
||||
@@ -309,6 +309,7 @@ export type ProviderProtocol =
|
||||
| 'bedrock-converse-stream'
|
||||
| 'ollama'
|
||||
| 'azure-openai-responses';
|
||||
export type ProviderReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
|
||||
export type ProviderConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -317,6 +318,8 @@ export type ProviderConfig = {
|
||||
apiProtocol?: ProviderProtocol;
|
||||
headers?: Record<string, string>;
|
||||
model?: string;
|
||||
reasoningEnabled?: boolean;
|
||||
reasoningEfforts?: ProviderReasoningEffort[];
|
||||
fallbackModels?: string[];
|
||||
fallbackProviderIds?: string[];
|
||||
enabled: boolean;
|
||||
@@ -365,6 +368,8 @@ export type ProviderAccount = {
|
||||
apiProtocol?: ProviderProtocol;
|
||||
headers?: Record<string, string>;
|
||||
model?: string;
|
||||
reasoningEnabled?: boolean;
|
||||
reasoningEfforts?: ProviderReasoningEffort[];
|
||||
fallbackModels?: string[];
|
||||
fallbackAccountIds?: string[];
|
||||
enabled: boolean;
|
||||
|
||||
@@ -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",
|
||||
@@ -216,11 +217,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",
|
||||
"reasoningEffortDescription": "Higher effort gives more thorough responses, but takes longer and uses context faster.",
|
||||
"reasoningEffortDefault": "Default",
|
||||
"reasoningEffortUpdateFailed": "Failed to update reasoning effort: {{error}}",
|
||||
"gatewayDisconnectedPlaceholder": "Gateway not connected...",
|
||||
"send": "Send",
|
||||
"stop": "Stop",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"cancelled": "キャンセル済み",
|
||||
"loadFailed": "セッションの読み込みに失敗しました",
|
||||
"promptFailed": "プロンプトの送信に失敗しました",
|
||||
"promptAborted": "モデルリクエストは応答を生成する前に中断されました。もう一度お試しください。",
|
||||
"unsupportedContent": "未対応のコンテンツ",
|
||||
"dismiss": "閉じる",
|
||||
"image": "画像",
|
||||
@@ -216,8 +217,14 @@
|
||||
"skillEmpty": "一致する Skill がありません",
|
||||
"pickAgent": "Agent を選択",
|
||||
"pickModel": "モデルを選択",
|
||||
"modelControlTitle": "モデルと推論強度",
|
||||
"modelSectionTitle": "モデル",
|
||||
"modelPickerTitle": "モデルを切り替え",
|
||||
"modelSwitchFailed": "モデルの切り替えに失敗しました: {{error}}",
|
||||
"reasoningEffortTitle": "推論強度",
|
||||
"reasoningEffortDescription": "高い強度ほど回答は詳細になりますが、時間がかかり、コンテキストをより速く消費します。",
|
||||
"reasoningEffortDefault": "デフォルト",
|
||||
"reasoningEffortUpdateFailed": "推論強度の更新に失敗しました: {{error}}",
|
||||
"clearTarget": "送信先 Agent をクリア",
|
||||
"targetChip": "@{{agent}}",
|
||||
"agentPickerTitle": "次のメッセージを別の Agent に直接送信します",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"cancelled": "Отменено",
|
||||
"loadFailed": "Не удалось загрузить сеанс",
|
||||
"promptFailed": "Не удалось отправить запрос",
|
||||
"promptAborted": "Запрос к модели был прерван до получения ответа. Повторите попытку.",
|
||||
"unsupportedContent": "Неподдерживаемое содержимое",
|
||||
"dismiss": "Закрыть",
|
||||
"image": "Изображение",
|
||||
@@ -216,8 +217,14 @@
|
||||
"skillEmpty": "Подходящие Skill не найдены",
|
||||
"pickAgent": "Выбрать агента",
|
||||
"pickModel": "Выбрать модель",
|
||||
"modelControlTitle": "Модель и глубина рассуждений",
|
||||
"modelSectionTitle": "Модель",
|
||||
"modelPickerTitle": "Переключить модель",
|
||||
"modelSwitchFailed": "Не удалось переключить модель: {{error}}",
|
||||
"reasoningEffortTitle": "Глубина рассуждений",
|
||||
"reasoningEffortDescription": "Более высокий уровень даёт более подробные ответы, но требует больше времени и быстрее расходует контекст.",
|
||||
"reasoningEffortDefault": "По умолчанию",
|
||||
"reasoningEffortUpdateFailed": "Не удалось изменить глубину рассуждений: {{error}}",
|
||||
"clearTarget": "Очистить целевого агента",
|
||||
"targetChip": "@{{agent}}",
|
||||
"agentPickerTitle": "Направить следующее сообщение другому агенту",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"cancelled": "已取消",
|
||||
"loadFailed": "会话加载失败",
|
||||
"promptFailed": "发送提示失败",
|
||||
"promptAborted": "模型请求在生成回复前已中止,请重试。",
|
||||
"unsupportedContent": "不支持的内容",
|
||||
"dismiss": "关闭",
|
||||
"image": "图片",
|
||||
@@ -216,8 +217,14 @@
|
||||
"skillEmpty": "未找到匹配的技能",
|
||||
"pickAgent": "选择 Agent",
|
||||
"pickModel": "选择模型",
|
||||
"modelControlTitle": "模型与推理强度",
|
||||
"modelSectionTitle": "模型",
|
||||
"modelPickerTitle": "切换当前 Agent 模型",
|
||||
"modelSwitchFailed": "模型切换失败:{{error}}",
|
||||
"reasoningEffortTitle": "推理强度",
|
||||
"reasoningEffortDescription": "更高档位会带来更深入的回答,但耗时更长,也会更快消耗上下文。",
|
||||
"reasoningEffortDefault": "默认",
|
||||
"reasoningEffortUpdateFailed": "推理强度更新失败:{{error}}",
|
||||
"clearTarget": "清除目标 Agent",
|
||||
"targetChip": "@{{agent}}",
|
||||
"agentPickerTitle": "将下一条消息直接发送给其他 Agent",
|
||||
|
||||
@@ -708,32 +708,34 @@ function ProviderCard({
|
||||
</div>
|
||||
)}
|
||||
{account.vendorId === 'custom' && (
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<Label className={currentLabelClasses}>{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
|
||||
<div className="flex gap-2 text-meta">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('openai-completions')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-completions' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.openaiCompletions', 'OpenAI Completions')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('openai-responses')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-responses' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.openaiResponses', 'OpenAI Responses')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('anthropic-messages')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'anthropic-messages' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.anthropic', 'Anthropic')}
|
||||
</button>
|
||||
<>
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<Label className={currentLabelClasses}>{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
|
||||
<div className="flex gap-2 text-meta">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('openai-completions')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-completions' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.openaiCompletions', 'OpenAI Completions')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('openai-responses')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-responses' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.openaiResponses', 'OpenAI Responses')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('anthropic-messages')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'anthropic-messages' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.anthropic', 'Anthropic')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showUserAgentField && (
|
||||
<div className="space-y-1.5 pt-2">
|
||||
@@ -1553,32 +1555,34 @@ function AddProviderDialog({
|
||||
</div>
|
||||
)}
|
||||
{selectedType === 'custom' && (
|
||||
<div className="space-y-2.5">
|
||||
<Label className={labelClasses}>{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
|
||||
<div className="flex gap-2 text-meta">
|
||||
<button
|
||||
type="button"
|
||||
<>
|
||||
<div className="space-y-2.5">
|
||||
<Label className={labelClasses}>{t('aiProviders.dialog.protocol', 'Protocol')}</Label>
|
||||
<div className="flex gap-2 text-meta">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('openai-completions')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-completions' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.openaiCompletions', 'OpenAI Completions')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('openai-responses')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-responses' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.openaiResponses', 'OpenAI Responses')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('anthropic-messages')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'anthropic-messages' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.anthropic', 'Anthropic')}
|
||||
</button>
|
||||
>
|
||||
{t('aiProviders.protocols.openaiCompletions', 'OpenAI Completions')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('openai-responses')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'openai-responses' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.openaiResponses', 'OpenAI Responses')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setApiProtocol('anthropic-messages')}
|
||||
className={cn("flex-1 py-1.5 px-3 rounded-lg border transition-colors", apiProtocol === 'anthropic-messages' ? "bg-surface-modal border-black/20 dark:border-white/20 shadow-sm font-medium" : "border-transparent bg-black/5 dark:bg-white/5 text-muted-foreground hover:bg-black/10 dark:hover:bg-white/10")}
|
||||
>
|
||||
{t('aiProviders.protocols.anthropic', 'Anthropic')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showUserAgentInAddDialog && (
|
||||
<div className="space-y-2.5">
|
||||
|
||||
@@ -36,6 +36,7 @@ export type ProviderProtocol =
|
||||
| 'bedrock-converse-stream'
|
||||
| 'ollama'
|
||||
| 'azure-openai-responses';
|
||||
export type ProviderReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
|
||||
|
||||
export const BUILTIN_PROVIDER_TYPES = [
|
||||
'anthropic',
|
||||
@@ -65,6 +66,8 @@ export interface ProviderConfig {
|
||||
apiProtocol?: ProviderProtocol;
|
||||
headers?: Record<string, string>;
|
||||
model?: string;
|
||||
reasoningEnabled?: boolean;
|
||||
reasoningEfforts?: ProviderReasoningEffort[];
|
||||
fallbackModels?: string[];
|
||||
fallbackProviderIds?: string[];
|
||||
enabled: boolean;
|
||||
@@ -133,6 +136,8 @@ export interface ProviderAccount {
|
||||
apiProtocol?: ProviderProtocol;
|
||||
headers?: Record<string, string>;
|
||||
model?: string;
|
||||
reasoningEnabled?: boolean;
|
||||
reasoningEfforts?: ProviderReasoningEffort[];
|
||||
fallbackModels?: string[];
|
||||
fallbackAccountIds?: string[];
|
||||
enabled: boolean;
|
||||
|
||||
@@ -133,6 +133,8 @@ export function AcpAssistantTurn({
|
||||
}) {
|
||||
const clipboardText = useMemo(() => assistantTurnClipboardText(group), [group]);
|
||||
const renderItems = useMemo(() => partitionTurnItems(group.items), [group.items]);
|
||||
const hideToolPresentation = timing?.status === 'running';
|
||||
const collapseSingleToolsByDefault = timing?.status === 'complete';
|
||||
|
||||
return (
|
||||
<div data-testid="acp-assistant-turn" className="group flex w-full justify-start gap-3">
|
||||
@@ -145,6 +147,7 @@ export function AcpAssistantTurn({
|
||||
<div className="flex min-w-0 flex-1 flex-col items-start gap-3">
|
||||
{renderItems.map((item) => {
|
||||
if (item.kind === 'tool-call-group') {
|
||||
if (hideToolPresentation) return null;
|
||||
return (
|
||||
<div key={item.id} className="w-full">
|
||||
<AcpToolCallsGroup
|
||||
@@ -176,9 +179,10 @@ export function AcpAssistantTurn({
|
||||
}
|
||||
|
||||
if (item.kind === 'tool-call') {
|
||||
if (hideToolPresentation) return null;
|
||||
return (
|
||||
<div key={item.id} data-acp-item-id={item.id} className="-my-1 w-full">
|
||||
<AcpToolCallCard item={item} />
|
||||
<AcpToolCallCard item={item} collapsedByDefault={collapseSingleToolsByDefault} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,11 +39,19 @@ function AcpToolOutputPart({ part }: { part: RenderPart }) {
|
||||
return <AcpRenderPart part={part} tone="process" />;
|
||||
}
|
||||
|
||||
export function AcpToolCallCard({ item, grouped = false }: { item: ToolCallItem; grouped?: boolean }) {
|
||||
export function AcpToolCallCard({
|
||||
item,
|
||||
grouped = false,
|
||||
collapsedByDefault = false,
|
||||
}: {
|
||||
item: ToolCallItem;
|
||||
grouped?: boolean;
|
||||
collapsedByDefault?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation('chat');
|
||||
const hasDetails = Boolean(item.error) || item.outputParts.length > 0;
|
||||
const isFinished = item.status === 'completed' || item.status === 'failed';
|
||||
const shouldStartExpanded = !hasDetails || !(item.historical && isFinished);
|
||||
const shouldStartExpanded = !hasDetails || (!collapsedByDefault && !(item.historical && isFinished));
|
||||
const [expansionState, setExpansionState] = useState<ExpansionState>(() => ({
|
||||
toolCallId: item.toolCallId,
|
||||
expanded: shouldStartExpanded,
|
||||
@@ -57,13 +65,13 @@ export function AcpToolCallCard({ item, grouped = false }: { item: ToolCallItem;
|
||||
if (!hasDetails) return true;
|
||||
if (manualOverride) return currentExpansionState.expanded;
|
||||
if (item.historical && isFinished) return false;
|
||||
if (!isFinished) return true;
|
||||
if (!isFinished && !collapsedByDefault) return true;
|
||||
return currentExpansionState.expanded;
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (manualOverride) return;
|
||||
if (!hasDetails || item.historical || !isFinished) return;
|
||||
if (!hasDetails || collapsedByDefault || item.historical || !isFinished) return;
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setExpansionState((state) => {
|
||||
@@ -75,7 +83,7 @@ export function AcpToolCallCard({ item, grouped = false }: { item: ToolCallItem;
|
||||
});
|
||||
}, TOOL_AUTO_COLLAPSE_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [hasDetails, item.historical, isFinished, item.toolCallId, manualOverride, shouldStartExpanded]);
|
||||
}, [collapsedByDefault, hasDetails, item.historical, isFinished, item.toolCallId, manualOverride, shouldStartExpanded]);
|
||||
|
||||
const toggleLabel = expanded ? t('acp.collapseTool') : t('acp.expandTool');
|
||||
|
||||
|
||||
+184
-34
@@ -7,7 +7,7 @@
|
||||
* references in the ACP session/prompt request.
|
||||
*/
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, FolderOpen, Loader2, AtSign, Search, ChevronDown, Check } from 'lucide-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 { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -48,7 +48,12 @@ export interface ChatWorkspaceOption {
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => void;
|
||||
onSend: (
|
||||
text: string,
|
||||
attachments?: FileAttachment[],
|
||||
targetAgentId?: string | null,
|
||||
thinkingLevel?: string,
|
||||
) => void;
|
||||
onStop?: () => void;
|
||||
disabled?: boolean;
|
||||
sending?: boolean;
|
||||
@@ -63,6 +68,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`;
|
||||
@@ -215,6 +221,7 @@ export function ChatInput({
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [skillPickerOpen, setSkillPickerOpen] = useState(false);
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
||||
const [reasoningMenuOpen, setReasoningMenuOpen] = useState(false);
|
||||
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||
const [skillQuery, setSkillQuery] = useState('');
|
||||
const [quickSkills, setQuickSkills] = useState<QuickAccessSkill[]>([]);
|
||||
@@ -241,6 +248,11 @@ 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 thinkingDefaults = useChatStore((s) => s.thinkingDefaults);
|
||||
const thinkingLevelUpdatingSessionKey = useChatStore((s) => s.thinkingLevelUpdatingSessionKey);
|
||||
const updateSessionThinkingLevel = useChatStore((s) => s.updateSessionThinkingLevel);
|
||||
const currentAgent = useMemo(
|
||||
() => (agents ?? []).find((agent) => agent.id === currentAgentId) ?? null,
|
||||
[agents, currentAgentId],
|
||||
@@ -263,10 +275,55 @@ export function ChatInput({
|
||||
[currentAgent?.modelRef, defaultModelRef, modelOptions],
|
||||
);
|
||||
const effectiveModelRef = optimisticModelRef || configuredModelRef;
|
||||
const currentModelOption = useMemo(
|
||||
() => modelOptions.find((option) => option.modelRef === effectiveModelRef),
|
||||
[effectiveModelRef, modelOptions],
|
||||
);
|
||||
const currentModelAccount = useMemo(
|
||||
() => providerAccounts.find((account) => account.id === currentModelOption?.accountId),
|
||||
[currentModelOption?.accountId, providerAccounts],
|
||||
);
|
||||
const canConfigureThinking = currentModelAccount?.vendorId === 'custom';
|
||||
const currentModelLabel = useMemo(() => {
|
||||
const matchedOption = modelOptions.find((option) => option.modelRef === effectiveModelRef);
|
||||
return matchedOption?.label || formatModelRefLabel(effectiveModelRef);
|
||||
}, [effectiveModelRef, modelOptions]);
|
||||
return currentModelOption?.label || formatModelRefLabel(effectiveModelRef);
|
||||
}, [currentModelOption?.label, effectiveModelRef]);
|
||||
const currentSession = useMemo(
|
||||
() => sessions.find((session) => session.key === currentSessionKey),
|
||||
[currentSessionKey, sessions],
|
||||
);
|
||||
const canUseThinkingDefaults = currentSession?.createdLocally === true
|
||||
&& thinkingDefaults?.agentId === currentAgentId;
|
||||
const thinkingOptions = useMemo(() => {
|
||||
if (!canConfigureThinking) return [];
|
||||
if (currentSession?.thinkingLevels?.length) return currentSession.thinkingLevels;
|
||||
return canUseThinkingDefaults ? (thinkingDefaults?.thinkingLevels ?? []) : [];
|
||||
}, [
|
||||
canConfigureThinking,
|
||||
canUseThinkingDefaults,
|
||||
currentSession?.thinkingLevels,
|
||||
thinkingDefaults?.thinkingLevels,
|
||||
]);
|
||||
const effortOptions = useMemo(
|
||||
() => SELECTABLE_THINKING_LEVEL_IDS.flatMap((id) => {
|
||||
const option = thinkingOptions.find((candidate) => candidate.id === id);
|
||||
return option ? [option] : [];
|
||||
}),
|
||||
[thinkingOptions],
|
||||
);
|
||||
const effectiveThinkingLevel = currentSession?.thinkingLevel
|
||||
?? currentSession?.thinkingDefault
|
||||
?? (canUseThinkingDefaults ? thinkingDefaults?.thinkingDefault : undefined);
|
||||
const currentThinkingLabel = useMemo(() => {
|
||||
if (!canConfigureThinking || !effectiveThinkingLevel) {
|
||||
return '';
|
||||
}
|
||||
return thinkingOptions.find((option) => option.id === effectiveThinkingLevel)?.label
|
||||
?? effectiveThinkingLevel;
|
||||
}, [canConfigureThinking, effectiveThinkingLevel, thinkingOptions]);
|
||||
const currentModelControlLabel = currentThinkingLabel
|
||||
? `${currentModelLabel} · ${currentThinkingLabel}`
|
||||
: currentModelLabel;
|
||||
const switchingThinkingLevel = thinkingLevelUpdatingSessionKey === currentSessionKey;
|
||||
const mentionableAgents = useMemo(
|
||||
() => (agents ?? []).filter((agent) => agent.id !== currentAgentId),
|
||||
[agents, currentAgentId],
|
||||
@@ -285,7 +342,7 @@ export function ChatInput({
|
||||
);
|
||||
}, [quickSkills, skillQuery]);
|
||||
const showAgentPicker = mentionableAgents.length > 0;
|
||||
const showModelPicker = modelOptions.length > 1;
|
||||
const showModelPicker = modelOptions.length > 1 || effortOptions.length > 0;
|
||||
const chatComposerStatusComponents = rendererExtensionRegistry.getChatComposerStatusComponents();
|
||||
const isGatewayUsable = gatewayStatus.state === 'running' && gatewayStatus.gatewayReady !== false;
|
||||
const inputDisabled = disabled;
|
||||
@@ -326,6 +383,10 @@ export function ChatInput({
|
||||
setOptimisticModelRef(null);
|
||||
}, [currentAgent?.modelRef, currentAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modelPickerOpen) setReasoningMenuOpen(false);
|
||||
}, [modelPickerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceSelectorDisabled) {
|
||||
setWorkspaceMenuOpen(false);
|
||||
@@ -523,6 +584,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 +772,7 @@ export function ChatInput({
|
||||
&& allReady
|
||||
&& !inputDisabled
|
||||
&& !sending
|
||||
&& !switchingThinkingLevel
|
||||
&& !imageGenerating;
|
||||
const canStop = sending && !inputDisabled && !!onStop;
|
||||
|
||||
@@ -727,12 +812,17 @@ export function ChatInput({
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
onSend(textToSend, attachmentsToSend, targetAgentId);
|
||||
onSend(
|
||||
textToSend,
|
||||
attachmentsToSend,
|
||||
targetAgentId,
|
||||
currentSession?.thinkingLevel?.trim() || undefined,
|
||||
);
|
||||
setTargetAgentId(null);
|
||||
setPickerOpen(false);
|
||||
setSkillPickerOpen(false);
|
||||
setWorkspaceMenuOpen(false);
|
||||
}, [input, attachments, canSend, onSend, targetAgentId]);
|
||||
}, [input, attachments, canSend, currentSession?.thinkingLevel, onSend, targetAgentId]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (!canStop) return;
|
||||
@@ -1136,8 +1226,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,42 +1235,102 @@ 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 && (
|
||||
<div
|
||||
className="absolute left-0 bottom-full z-20 mb-2 w-72 overflow-hidden rounded-2xl border border-black/10 bg-surface-modal p-1.5 shadow-xl dark:border-white/10"
|
||||
className="absolute left-0 bottom-full z-20 mb-2 w-72 overflow-visible rounded-2xl border border-black/10 bg-surface-modal p-1.5 shadow-xl dark:border-white/10"
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{effortOptions.length > 0 && (
|
||||
<>
|
||||
{modelOptions.length > 1 && <div className="mx-2 my-1.5 h-px bg-border" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReasoningMenuOpen((open) => !open)}
|
||||
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',
|
||||
reasoningMenuOpen
|
||||
? 'bg-black/5 text-foreground dark:bg-white/10'
|
||||
: 'hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
data-testid="chat-reasoning-effort-menu-trigger"
|
||||
>
|
||||
<span>{t('composer.reasoningEffortTitle')}</span>
|
||||
<span className="flex min-w-0 items-center gap-1 text-muted-foreground">
|
||||
<span className="truncate">{currentThinkingLabel || t('composer.reasoningEffortDefault')}</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{reasoningMenuOpen && (
|
||||
<div
|
||||
className="absolute bottom-0 right-full z-30 mr-2 w-52 rounded-2xl border border-black/10 bg-surface-modal p-1.5 shadow-xl dark:border-white/10"
|
||||
data-testid="chat-reasoning-effort-menu"
|
||||
>
|
||||
<p className="px-3 py-2 text-xs leading-5 text-muted-foreground">
|
||||
{t('composer.reasoningEffortDescription')}
|
||||
</p>
|
||||
{effortOptions.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',
|
||||
effectiveThinkingLevel === 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>
|
||||
{effectiveThinkingLevel === option.id && (
|
||||
<Check className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -545,7 +545,12 @@ export function Chat() {
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
onSend={(text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => {
|
||||
onSend={(
|
||||
text: string,
|
||||
attachments?: FileAttachment[],
|
||||
targetAgentId?: string | null,
|
||||
thinkingLevel?: string,
|
||||
) => {
|
||||
if (!currentSessionKey || !cwd || !workspaceContextAvailable) return;
|
||||
const targetAgent = targetAgentId
|
||||
? agents.find((agent) => agent.id === targetAgentId) ?? null
|
||||
@@ -554,6 +559,7 @@ export function Chat() {
|
||||
? targetAgent.mainSessionKey || `agent:${targetAgent.id}:main`
|
||||
: currentSessionKey;
|
||||
const existingSession = sessions.find((session) => session.key === sessionKey);
|
||||
const selectedThinkingLevel = targetAgent ? undefined : thinkingLevel?.trim();
|
||||
setLastPromptAttemptSessionKey(sessionKey);
|
||||
const promptCwd = targetAgent?.workspace || cwd;
|
||||
const media = attachments
|
||||
@@ -608,6 +614,7 @@ export function Chat() {
|
||||
cwd: promptCwd,
|
||||
message: text,
|
||||
media,
|
||||
...(selectedThinkingLevel ? { thinkingLevel: selectedThinkingLevel } : {}),
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
void scrollToBottom({ animation: 'instant', ignoreEscapes: true });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+112
-1
@@ -13,6 +13,7 @@ import { useGatewayStore } from './gateway';
|
||||
import { pickStartupSessionFallback } from './chat/session-selection';
|
||||
import {
|
||||
applyGatewaySessionsChanged,
|
||||
normalizeGatewaySessionPatch,
|
||||
normalizeGatewaySessionRow,
|
||||
type GatewaySessionsChangedPayload,
|
||||
} from './chat/session-catalog';
|
||||
@@ -486,12 +487,21 @@ async function fetchChatSessionsList(): Promise<Record<string, unknown>> {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAgentThinkingDefaults(agentId: string): Promise<Record<string, unknown>> {
|
||||
return useGatewayStore.getState().rpc<Record<string, unknown>>('sessions.list', {
|
||||
agentId,
|
||||
limit: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
sessions: [],
|
||||
currentSessionKey: DEFAULT_SESSION_KEY,
|
||||
currentAgentId: 'main',
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
thinkingDefaults: null,
|
||||
thinkingLevelUpdatingSessionKey: null,
|
||||
|
||||
loadSessions: async (options) => {
|
||||
const requestedGeneration = options?.gatewayGeneration;
|
||||
@@ -546,8 +556,42 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
try {
|
||||
const localRevisionBeforeRequest = localSessionCatalogRevision;
|
||||
const data = await fetchChatSessionsList();
|
||||
const stateBeforeRequest = get();
|
||||
const requestedAgentId = stateBeforeRequest.currentAgentId;
|
||||
const selectedSessionBeforeRequest = stateBeforeRequest.sessions.find(
|
||||
(session) => session.key === stateBeforeRequest.currentSessionKey,
|
||||
);
|
||||
const shouldFetchScopedDefaults = requestedAgentId !== 'main'
|
||||
&& selectedSessionBeforeRequest?.createdLocally === true;
|
||||
const [data, scopedDefaultsData] = await Promise.all([
|
||||
fetchChatSessionsList(),
|
||||
shouldFetchScopedDefaults ? fetchAgentThinkingDefaults(requestedAgentId) : null,
|
||||
]);
|
||||
const defaultsData = scopedDefaultsData ?? data;
|
||||
if (generation === sessionCatalogGeneration) {
|
||||
const rawDefaults = defaultsData.defaults
|
||||
&& typeof defaultsData.defaults === 'object'
|
||||
&& !Array.isArray(defaultsData.defaults)
|
||||
? defaultsData.defaults as Record<string, unknown>
|
||||
: null;
|
||||
const normalizedDefaults = rawDefaults
|
||||
? normalizeGatewaySessionRow({ ...rawDefaults, key: '__gateway_defaults__' })
|
||||
: null;
|
||||
const thinkingDefaults = normalizedDefaults
|
||||
? {
|
||||
agentId: requestedAgentId,
|
||||
...(normalizedDefaults.modelProvider
|
||||
? { modelProvider: normalizedDefaults.modelProvider }
|
||||
: {}),
|
||||
...(normalizedDefaults.model ? { model: normalizedDefaults.model } : {}),
|
||||
...(normalizedDefaults.thinkingLevels
|
||||
? { thinkingLevels: normalizedDefaults.thinkingLevels }
|
||||
: {}),
|
||||
...(normalizedDefaults.thinkingDefault
|
||||
? { thinkingDefault: normalizedDefaults.thinkingDefault }
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
const normalizedSessions = rawSessions.map((session) => (
|
||||
normalizeGatewaySessionRow(session as Record<string, unknown>)
|
||||
@@ -919,6 +963,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||
sessionLabels,
|
||||
sessionLastActivity,
|
||||
thinkingDefaults,
|
||||
};
|
||||
});
|
||||
applySessionBackendLabels(set, sessionsWithCurrent);
|
||||
@@ -1374,4 +1419,70 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
sessionLabels: { ...state.sessionLabels, [key]: normalized },
|
||||
}));
|
||||
},
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -20,6 +20,8 @@ const STRING_FIELDS = [
|
||||
'derivedTitle',
|
||||
'lastMessagePreview',
|
||||
'thinkingLevel',
|
||||
'thinkingDefault',
|
||||
'modelProvider',
|
||||
'model',
|
||||
'workspacePath',
|
||||
] as const satisfies readonly SessionField[];
|
||||
@@ -70,6 +72,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) {
|
||||
|
||||
@@ -114,6 +114,8 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
apiProtocol: config.apiProtocol,
|
||||
headers: config.headers,
|
||||
model: config.model,
|
||||
reasoningEnabled: config.reasoningEnabled,
|
||||
reasoningEfforts: config.reasoningEfforts,
|
||||
fallbackModels: config.fallbackModels,
|
||||
fallbackAccountIds: config.fallbackProviderIds,
|
||||
enabled: config.enabled,
|
||||
@@ -156,6 +158,8 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
if (updates.apiProtocol !== undefined) accountUpdates.apiProtocol = updates.apiProtocol;
|
||||
if (updates.headers !== undefined) accountUpdates.headers = updates.headers;
|
||||
if (updates.model !== undefined) accountUpdates.model = updates.model;
|
||||
if (updates.reasoningEnabled !== undefined) accountUpdates.reasoningEnabled = updates.reasoningEnabled;
|
||||
if (updates.reasoningEfforts !== undefined) accountUpdates.reasoningEfforts = updates.reasoningEfforts;
|
||||
if (updates.fallbackModels !== undefined) accountUpdates.fallbackModels = updates.fallbackModels;
|
||||
if (updates.fallbackProviderIds !== undefined) accountUpdates.fallbackAccountIds = updates.fallbackProviderIds;
|
||||
if (updates.enabled !== undefined) accountUpdates.enabled = updates.enabled;
|
||||
|
||||
@@ -779,55 +779,66 @@ test.describe('ClawX ACP inline timeline', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('auto-collapses completed tool cards and respects manual override', async ({ launchElectronApp }) => {
|
||||
test('hides tool activity during a response, then reveals every tool collapsed', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
await installAcpChatMocks(app);
|
||||
await installAcpPromptDeferredMock(app);
|
||||
const page = await openChat(app);
|
||||
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId('chat-composer-input').fill('Use multiple tools');
|
||||
await page.getByTestId('chat-composer-send').click();
|
||||
await emitAcpSessionUpdates(app, [
|
||||
{
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'collapse-tool',
|
||||
title: 'Collapsible tool',
|
||||
toolCallId: 'collapse-tool-1',
|
||||
title: 'First collapsible tool',
|
||||
status: 'in_progress',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'collapsible output' } }],
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'first output' } }],
|
||||
locations: [],
|
||||
},
|
||||
{
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'collapse-tool-2',
|
||||
title: 'Second collapsible tool',
|
||||
status: 'in_progress',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'second output' } }],
|
||||
locations: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const card = page.getByTestId('acp-tool-call-card');
|
||||
await expect(card).toHaveAttribute('data-expanded', 'true', { timeout: 30_000 });
|
||||
await expect(page.getByTestId('acp-tool-calls-group')).toHaveCount(0);
|
||||
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(0);
|
||||
|
||||
await emitAcpSessionUpdates(app, [
|
||||
{
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'collapse-tool',
|
||||
toolCallId: 'collapse-tool-1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'collapsible output' } }],
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'first output' } }],
|
||||
locations: [],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(card).toHaveAttribute('data-expanded', 'false', { timeout: 30_000 });
|
||||
|
||||
await page.getByTestId('acp-tool-toggle').click();
|
||||
await expect(card).toHaveAttribute('data-expanded', 'true');
|
||||
|
||||
await emitAcpSessionUpdates(app, [
|
||||
{
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'collapse-tool',
|
||||
toolCallId: 'collapse-tool-2',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'collapsible output after override' } }],
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'second output' } }],
|
||||
locations: [],
|
||||
},
|
||||
]);
|
||||
await expect(page.getByTestId('acp-tool-calls-group')).toHaveCount(0);
|
||||
|
||||
await page.waitForTimeout(1_200);
|
||||
await expect(card).toHaveAttribute('data-expanded', 'true');
|
||||
await resolveDeferredAcpPrompt(app);
|
||||
|
||||
const group = page.getByTestId('acp-tool-calls-group');
|
||||
await expect(group).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(group).toHaveAttribute('data-collapsed', 'true');
|
||||
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(0);
|
||||
|
||||
await expandAcpToolCallsGroup(page);
|
||||
await expect(page.getByTestId('acp-tool-call-card')).toHaveCount(2);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,14 @@ 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;
|
||||
let thinkingSessionMaterialized = false;
|
||||
const thinkingLevels = [
|
||||
{ id: 'off', label: 'Off' },
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
];
|
||||
const hostRequests: Array<{ path: string; method: string; body: unknown }> = [];
|
||||
const now = new Date().toISOString();
|
||||
let releaseProviderAccounts: (() => void) | undefined;
|
||||
@@ -57,7 +65,24 @@ 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: {
|
||||
defaults: {
|
||||
modelProvider: currentModelRef.split('/')[0],
|
||||
model: currentModelRef.split('/').slice(1).join('/'),
|
||||
thinkingDefault: 'medium',
|
||||
thinkingLevels,
|
||||
},
|
||||
sessions: thinkingSessionMaterialized ? [{
|
||||
key: 'agent:main:main',
|
||||
displayName: 'main',
|
||||
thinkingLevel: currentThinkingLevel,
|
||||
thinkingDefault: 'medium',
|
||||
thinkingLevels,
|
||||
}] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
return { success: true, result: {} };
|
||||
});
|
||||
@@ -98,12 +123,47 @@ 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;
|
||||
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, {
|
||||
defaults: {
|
||||
modelProvider: currentModelRef.split('/')[0],
|
||||
model: currentModelRef.split('/').slice(1).join('/'),
|
||||
thinkingDefault: 'medium',
|
||||
thinkingLevels,
|
||||
},
|
||||
sessions: thinkingSessionMaterialized ? [{
|
||||
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;
|
||||
thinkingSessionMaterialized = true;
|
||||
return makeResponse(request.id, {
|
||||
ok: true,
|
||||
key: 'agent:main:main',
|
||||
entry: { thinkingLevel: currentThinkingLevel },
|
||||
resolved: {
|
||||
thinkingLevel: currentThinkingLevel ?? 'medium',
|
||||
thinkingLevels,
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(request.id, { success: true, result: {} });
|
||||
}
|
||||
@@ -227,7 +287,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)');
|
||||
@@ -237,8 +297,32 @@ test.describe('ClawX chat model picker', () => {
|
||||
await expect(page.getByTestId('chat-model-picker-menu')).toContainText('kimi-k2.7 (Moonshot)');
|
||||
await expect(page.getByTestId('chat-model-picker-menu')).not.toContainText('kimi-k2.6 (Moonshot)');
|
||||
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: 'gpt-5.6 (OpenAI)' }).click();
|
||||
await expect(page.getByTestId('chat-model-picker-button')).not.toContainText('Medium');
|
||||
await page.getByTestId('chat-model-picker-button').click();
|
||||
await expect(page.getByTestId('chat-reasoning-effort-menu-trigger')).toHaveCount(0);
|
||||
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 page.getByTestId('chat-reasoning-effort-menu-trigger').click();
|
||||
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-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();
|
||||
|
||||
await page.getByTestId('chat-model-picker-button').click();
|
||||
await page.getByTestId('chat-reasoning-effort-menu-trigger').click();
|
||||
await page.getByTestId('chat-reasoning-effort-option-off').click();
|
||||
await expect(page.getByTestId('chat-model-picker-button')).toContainText('provider/model-beta (Beta) · Off');
|
||||
|
||||
const requests = await app.evaluate(() => (
|
||||
(globalThis as typeof globalThis & { __chatModelPickerRequests?: Array<{ path: string; method: string; body: unknown }> }).__chatModelPickerRequests ?? []
|
||||
@@ -248,6 +332,27 @@ 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: 'high' },
|
||||
});
|
||||
expect(requests).toContainEqual({
|
||||
path: 'gateway:sessions.patch',
|
||||
method: 'RPC',
|
||||
body: { key: 'agent:main:main', thinkingLevel: 'off' },
|
||||
});
|
||||
expect(requests).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: 'chat:sendAcpPrompt',
|
||||
method: 'HOST',
|
||||
body: expect.objectContaining({
|
||||
sessionKey: 'agent:main:main',
|
||||
message: 'Trigger an aborted model request',
|
||||
thinkingLevel: 'high',
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
expect(requests.some((request) =>
|
||||
request.path === '/api/gateway/restart'
|
||||
|| request.path === '/api/gateway/start'
|
||||
|
||||
@@ -121,13 +121,14 @@ test.describe('ClawX provider lifecycle', () => {
|
||||
await page.getByTestId('add-provider-type-openai').click();
|
||||
await expect(page.getByTestId('add-provider-auth-oauth-tab')).toBeVisible();
|
||||
await expect(page.getByTestId('add-provider-auth-apikey-tab')).toBeVisible();
|
||||
await expect(page.getByTestId('add-provider-reasoning-toggle')).toHaveCount(0);
|
||||
|
||||
await page.getByTestId('add-provider-auth-oauth-tab').click();
|
||||
await expect(page.getByTestId('add-provider-oauth-login-button')).toBeVisible();
|
||||
await expect(page.getByTestId('add-provider-api-key-input')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('trims whitespace before validating and saving a custom provider key', async ({ electronApp, page }) => {
|
||||
test('saves a custom provider while trimming the key and without reasoning settings', async ({ electronApp, page }) => {
|
||||
await completeSetup(page);
|
||||
|
||||
await electronApp.evaluate(async ({ app: _app }) => {
|
||||
@@ -222,9 +223,13 @@ test.describe('ClawX provider lifecycle', () => {
|
||||
await page.getByTestId('add-provider-api-key-input').fill(' sk-lm-test \n');
|
||||
await page.getByTestId('add-provider-base-url-input').fill('http://127.0.0.1:1234/v1');
|
||||
await page.getByTestId('add-provider-model-id-input').fill('local-model');
|
||||
await expect(page.getByTestId('add-provider-reasoning-toggle')).toHaveCount(0);
|
||||
await page.getByTestId('add-provider-submit-button').click();
|
||||
|
||||
await expect(page.getByTestId('provider-card-custom')).toContainText('LM Studio Local');
|
||||
await page.getByTestId('provider-card-custom').hover();
|
||||
await page.getByTestId('provider-edit-custom').click();
|
||||
await expect(page.getByTestId('provider-edit-custom-reasoning-toggle')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('edit form validates the new API key inline before saving (single button)', async ({ electronApp, page }) => {
|
||||
|
||||
@@ -956,7 +956,7 @@ describe('ACP chat timeline components', () => {
|
||||
expect(screen.getByText('image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a running turn tool group expanded until the turn completes', () => {
|
||||
it('hides a live tool group, then reveals the whole group collapsed when the turn completes', () => {
|
||||
const state = snapshot({
|
||||
itemOrder: ['user-a:0', 'tool:exec-1', 'tool:image-1'],
|
||||
itemsById: {
|
||||
@@ -972,16 +972,61 @@ describe('ACP chat timeline components', () => {
|
||||
'tool:image-1': toolCallItem({ id: 'tool:image-1', toolCallId: 'image-1', title: 'image', status: 'completed', outputParts: [] }),
|
||||
},
|
||||
});
|
||||
|
||||
render(<AcpTimeline
|
||||
const { rerender } = render(<AcpTimeline
|
||||
snapshot={state}
|
||||
turnTimingsByUserMessageId={{
|
||||
'user-a': { source: 'live', status: 'running', startedAtMs: Date.now() - 2_000 },
|
||||
}}
|
||||
/>);
|
||||
|
||||
expect(screen.getByTestId('acp-tool-calls-group')).toHaveAttribute('data-collapsed', 'false');
|
||||
expect(screen.getAllByTestId('acp-tool-call-card')).toHaveLength(2);
|
||||
expect(screen.queryByTestId('acp-tool-calls-group')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('acp-tool-call-card')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<AcpTimeline
|
||||
snapshot={state}
|
||||
turnTimingsByUserMessageId={{
|
||||
'user-a': { source: 'live', status: 'complete', durationMs: 2_000 },
|
||||
}}
|
||||
/>);
|
||||
|
||||
expect(screen.getByTestId('acp-tool-calls-group')).toHaveAttribute('data-collapsed', 'true');
|
||||
expect(screen.queryByTestId('acp-tool-call-card')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides a single live tool, then reveals its details collapsed when the turn completes', () => {
|
||||
const state = snapshot({
|
||||
itemOrder: ['user-a:0', 'tool:read-file'],
|
||||
itemsById: {
|
||||
'user-a:0': {
|
||||
kind: 'message-segment',
|
||||
id: 'user-a:0',
|
||||
role: 'user',
|
||||
messageId: 'user-a',
|
||||
segmentIndex: 0,
|
||||
parts: [{ kind: 'markdown', text: 'Read it' }],
|
||||
},
|
||||
'tool:read-file': toolCallItem({ status: 'running' }),
|
||||
},
|
||||
});
|
||||
const { rerender } = render(<AcpTimeline
|
||||
snapshot={state}
|
||||
turnTimingsByUserMessageId={{
|
||||
'user-a': { source: 'live', status: 'running', startedAtMs: Date.now() - 1_000 },
|
||||
}}
|
||||
/>);
|
||||
|
||||
expect(screen.queryByTestId('acp-tool-call-card')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<AcpTimeline
|
||||
snapshot={state}
|
||||
turnTimingsByUserMessageId={{
|
||||
'user-a': { source: 'live', status: 'complete', durationMs: 1_000 },
|
||||
}}
|
||||
/>);
|
||||
|
||||
expect(screen.getByTestId('acp-tool-call-card')).toHaveAttribute('data-expanded', 'false');
|
||||
fireEvent.click(screen.getByTestId('acp-tool-toggle'));
|
||||
expect(screen.getByTestId('acp-tool-call-card')).toHaveAttribute('data-expanded', 'true');
|
||||
});
|
||||
|
||||
it('keeps completed tool results expanded until the delayed auto-collapse runs', () => {
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('AcpChatService', () => {
|
||||
expect(connection.loadSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes fresh-session prompts through the ACP session id returned by session/new', async () => {
|
||||
it('routes fresh-session prompts with the user-selected thinking level', async () => {
|
||||
const { service, connection } = await createService();
|
||||
|
||||
await service.loadSession({ sessionKey: 'agent:pi:session-123', workspaceRoot: '/repo', cwd: '/repo', createIfMissing: true });
|
||||
@@ -234,12 +234,18 @@ describe('AcpChatService', () => {
|
||||
cwd: '/repo',
|
||||
message: 'hello',
|
||||
messageId: 'msg-1',
|
||||
thinkingLevel: 'high',
|
||||
})).resolves.toEqual({ success: true, generation: 1 });
|
||||
|
||||
expect(connection.prompt).toHaveBeenCalledWith({
|
||||
sessionId: 'acp-session-1',
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
_meta: { sessionKey: 'agent:pi:session-123', prefixCwd: true, messageId: 'msg-1' },
|
||||
_meta: {
|
||||
sessionKey: 'agent:pi:session-123',
|
||||
prefixCwd: true,
|
||||
messageId: 'msg-1',
|
||||
thinkingLevel: 'high',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,6 +407,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,6 +13,11 @@ const { agentsState, chatState, gatewayState, providersState, artifactPanelMocks
|
||||
},
|
||||
chatState: {
|
||||
currentAgentId: 'main',
|
||||
currentSessionKey: 'agent:main:main',
|
||||
sessions: [] as Array<Record<string, unknown>>,
|
||||
thinkingDefaults: null as Record<string, unknown> | null,
|
||||
thinkingLevelUpdatingSessionKey: null as string | null,
|
||||
updateSessionThinkingLevel: vi.fn(),
|
||||
},
|
||||
gatewayState: {
|
||||
status: { state: 'running', port: 18789 },
|
||||
@@ -98,6 +103,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.reasoningEffortDescription':
|
||||
return 'Higher effort gives more thorough responses.';
|
||||
case 'composer.reasoningEffortDefault':
|
||||
return 'Default';
|
||||
case 'composer.reasoningEffortUpdateFailed':
|
||||
return `Failed to update reasoning effort: ${String(vars?.error ?? '')}`;
|
||||
case 'composer.clearTarget':
|
||||
return 'Clear target agent';
|
||||
case 'composer.targetChip':
|
||||
@@ -224,6 +241,11 @@ describe('ChatInput agent targeting', () => {
|
||||
agentsState.defaultModelRef = null;
|
||||
agentsState.updateAgentModel.mockReset();
|
||||
chatState.currentAgentId = 'main';
|
||||
chatState.currentSessionKey = 'agent:main:main';
|
||||
chatState.sessions = [];
|
||||
chatState.thinkingDefaults = null;
|
||||
chatState.thinkingLevelUpdatingSessionKey = null;
|
||||
chatState.updateSessionThinkingLevel.mockReset();
|
||||
gatewayState.status = { state: 'running', port: 18789 };
|
||||
providersState.accounts = [];
|
||||
providersState.statuses = [];
|
||||
@@ -680,6 +702,186 @@ 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: 'minimal', label: 'Minimal' },
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ 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();
|
||||
fireEvent.click(screen.getByTestId('chat-reasoning-effort-menu-trigger'));
|
||||
const reasoningMenu = screen.getByTestId('chat-reasoning-effort-menu');
|
||||
expect(reasoningMenu).toHaveClass('w-52');
|
||||
expect(reasoningMenu).not.toHaveClass('w-72');
|
||||
expect(reasoningMenu).toHaveTextContent(
|
||||
'Higher effort gives more thorough responses.',
|
||||
);
|
||||
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-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'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(chatState.updateSessionThinkingLevel).toHaveBeenCalledWith('agent:main:main', 'high');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Gateway agent defaults for a custom-model draft not yet listed as a session', () => {
|
||||
configureAgentAndModelPickers();
|
||||
chatState.sessions = [{
|
||||
key: chatState.currentSessionKey,
|
||||
createdLocally: true,
|
||||
}];
|
||||
chatState.thinkingDefaults = {
|
||||
agentId: 'main',
|
||||
modelProvider: 'custom-aaaaaaaa',
|
||||
model: 'gpt-a',
|
||||
thinkingDefault: 'medium',
|
||||
thinkingLevels: [
|
||||
{ id: 'off', label: 'Off' },
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
};
|
||||
|
||||
renderChatInput();
|
||||
|
||||
expect(screen.getByTestId('chat-model-picker-button')).toHaveTextContent('gpt-a (Alpha) · Medium');
|
||||
fireEvent.click(screen.getByTestId('chat-model-picker-button'));
|
||||
expect(screen.getByTestId('chat-reasoning-effort-menu-trigger')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not expose reasoning controls for a non-custom model', () => {
|
||||
configureAgentAndModelPickers();
|
||||
const now = '2025-01-01T00:00:00.000Z';
|
||||
agentsState.defaultModelRef = 'openai/gpt-a';
|
||||
providersState.accounts = [
|
||||
{
|
||||
id: 'openai',
|
||||
vendorId: 'openai',
|
||||
label: 'OpenAI',
|
||||
authMode: 'oauth_browser',
|
||||
model: 'openai/gpt-a',
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: 'moonshot',
|
||||
vendorId: 'moonshot',
|
||||
label: 'Moonshot',
|
||||
authMode: 'oauth_device',
|
||||
model: 'moonshot/kimi',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
providersState.statuses = [];
|
||||
providersState.defaultAccountId = 'openai';
|
||||
chatState.sessions = [{
|
||||
key: chatState.currentSessionKey,
|
||||
thinkingDefault: 'high',
|
||||
thinkingLevels: [
|
||||
{ id: 'off', label: 'Off' },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
}];
|
||||
|
||||
renderChatInput();
|
||||
|
||||
expect(screen.getByTestId('chat-model-picker-button')).not.toHaveTextContent('High');
|
||||
fireEvent.click(screen.getByTestId('chat-model-picker-button'));
|
||||
expect(screen.queryByTestId('chat-reasoning-effort-menu-trigger')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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('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-menu-trigger'));
|
||||
fireEvent.click(screen.getByTestId('chat-reasoning-effort-option-high'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
'Failed to update reasoning effort: Error: patch failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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: 'low', label: 'Low' },
|
||||
{ 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.queryByTestId('chat-thinking-toggle')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('chat-reasoning-effort-option-off'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(chatState.updateSessionThinkingLevel).toHaveBeenCalledWith('agent:main:main', 'off');
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the focused skill picker search with Escape', async () => {
|
||||
configureAgentAndModelPickers();
|
||||
|
||||
@@ -898,7 +1100,7 @@ describe('ChatInput agent targeting', () => {
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Hello direct agent' } });
|
||||
fireEvent.click(screen.getByTitle('Send'));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('Hello direct agent', undefined, 'research');
|
||||
expect(onSend).toHaveBeenCalledWith('Hello direct agent', undefined, 'research', undefined);
|
||||
});
|
||||
|
||||
it('keeps the ACP composer enabled while gateway is running but not yet ready', () => {
|
||||
@@ -950,6 +1152,10 @@ describe('ChatInput agent targeting', () => {
|
||||
{ id: 'bbbbbbbb', name: 'Beta', type: 'custom', hasKey: true, keyMasked: 'sk-***', enabled: true, createdAt: now, updatedAt: now },
|
||||
];
|
||||
providersState.defaultAccountId = 'aaaaaaaa';
|
||||
chatState.sessions = [{
|
||||
key: chatState.currentSessionKey,
|
||||
thinkingLevel: 'high',
|
||||
}];
|
||||
|
||||
renderChatInput(onSend);
|
||||
|
||||
@@ -961,7 +1167,7 @@ describe('ChatInput agent targeting', () => {
|
||||
fireEvent.change(input, { target: { value: 'Send through ACP' } });
|
||||
fireEvent.click(screen.getByTitle('Send'));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('Send through ACP', undefined, null);
|
||||
expect(onSend).toHaveBeenCalledWith('Send through ACP', undefined, null, 'high');
|
||||
});
|
||||
|
||||
it('shows starting status while gateway is running but not yet ready', () => {
|
||||
@@ -1068,7 +1274,7 @@ describe('ChatInput agent targeting', () => {
|
||||
|
||||
fireEvent.click(screen.getByTitle('Send'));
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('Draft /create-skill a new helper', undefined, null);
|
||||
expect(onSend).toHaveBeenCalledWith('Draft /create-skill a new helper', undefined, null, undefined);
|
||||
expect(hostApiFetchMock).toHaveBeenCalledWith(
|
||||
'/api/skills/quick-access',
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -113,6 +113,88 @@ describe('chat session catalog startup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('retains agent-scoped thinking defaults for a local draft session', async () => {
|
||||
gatewayRpcMock.mockResolvedValue({
|
||||
ts: 1,
|
||||
defaults: {
|
||||
modelProvider: 'custom-aaaaaaaa',
|
||||
model: 'gpt-a',
|
||||
thinkingDefault: 'medium',
|
||||
thinkingLevels: [
|
||||
{ id: 'off', label: 'Off' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
},
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:session-draft',
|
||||
currentAgentId: 'main',
|
||||
sessions: [{ key: 'agent:main:session-draft', createdLocally: true }],
|
||||
});
|
||||
|
||||
await useChatStore.getState().loadSessions({ force: true });
|
||||
|
||||
expect(useChatStore.getState().thinkingDefaults).toEqual({
|
||||
agentId: 'main',
|
||||
modelProvider: 'custom-aaaaaaaa',
|
||||
model: 'gpt-a',
|
||||
thinkingDefault: 'medium',
|
||||
thinkingLevels: [
|
||||
{ id: 'off', label: 'Off' },
|
||||
{ id: 'medium', label: 'Medium' },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('loads non-main draft defaults without scoping the sidebar catalog request', async () => {
|
||||
gatewayRpcMock.mockImplementation(async (_method: string, params?: Record<string, unknown>) => {
|
||||
if (params?.agentId === 'research') {
|
||||
return {
|
||||
defaults: {
|
||||
modelProvider: 'custom-research',
|
||||
model: 'reasoner',
|
||||
thinkingDefault: 'high',
|
||||
thinkingLevels: [
|
||||
{ id: 'off', label: 'Off' },
|
||||
{ id: 'high', label: 'High' },
|
||||
],
|
||||
},
|
||||
sessions: [],
|
||||
};
|
||||
}
|
||||
return { ts: 1, sessions: [] };
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:research:session-draft',
|
||||
currentAgentId: 'research',
|
||||
sessions: [{ key: 'agent:research:session-draft', createdLocally: true }],
|
||||
});
|
||||
|
||||
await useChatStore.getState().loadSessions({ force: true });
|
||||
|
||||
expect(gatewayRpcMock).toHaveBeenNthCalledWith(1, 'sessions.list', {
|
||||
includeDerivedTitles: true,
|
||||
includeLastMessage: true,
|
||||
});
|
||||
expect(gatewayRpcMock).toHaveBeenNthCalledWith(2, 'sessions.list', {
|
||||
agentId: 'research',
|
||||
limit: 1,
|
||||
});
|
||||
expect(useChatStore.getState().thinkingDefaults).toEqual(expect.objectContaining({
|
||||
agentId: 'research',
|
||||
modelProvider: 'custom-research',
|
||||
model: 'reasoner',
|
||||
thinkingDefault: 'high',
|
||||
}));
|
||||
});
|
||||
|
||||
it('hydrates workspace identity and title activity from session summaries', async () => {
|
||||
gatewayRpcMock.mockResolvedValue({
|
||||
ts: 1,
|
||||
|
||||
@@ -33,6 +33,9 @@ vi.mock('@/stores/gateway', () => ({
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
|
||||
hostApi: {
|
||||
gateway: {
|
||||
rpc: (...args: unknown[]) => gatewayRpcMock(...args),
|
||||
},
|
||||
sessions: {
|
||||
summaries: (input: unknown) => hostApiFetchMock('/api/sessions/summaries', {
|
||||
method: 'POST',
|
||||
@@ -93,6 +96,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');
|
||||
|
||||
|
||||
@@ -1051,6 +1051,9 @@ describe('syncProviderConfigToOpenClaw', () => {
|
||||
reasoning: true,
|
||||
contextWindow: 200000,
|
||||
customField: 'keep-me',
|
||||
compat: {
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -1073,12 +1076,35 @@ describe('syncProviderConfigToOpenClaw', () => {
|
||||
expect.objectContaining({
|
||||
id: 'private-model-x',
|
||||
input: ['text'],
|
||||
reasoning: true,
|
||||
compat: {
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes an inferred contextWindow for new custom-provider model rows', async () => {
|
||||
await writeOpenClawJson({ models: { providers: {} } });
|
||||
it('enables the default reasoning effort ladder on custom-provider primary models', async () => {
|
||||
await writeOpenClawJson({
|
||||
models: {
|
||||
providers: {
|
||||
'custom-example': {
|
||||
baseUrl: 'https://example.com/v1',
|
||||
api: 'openai-completions',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
name: 'gpt-5.5',
|
||||
input: ['text'],
|
||||
contextWindow: 1000000,
|
||||
reasoning: false,
|
||||
compat: { customFlag: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { syncProviderConfigToOpenClaw } = await import('@electron/utils/openclaw-auth');
|
||||
await syncProviderConfigToOpenClaw('custom-example', 'gpt-5.5', {
|
||||
@@ -1095,6 +1121,11 @@ describe('syncProviderConfigToOpenClaw', () => {
|
||||
expect.objectContaining({
|
||||
id: 'gpt-5.5',
|
||||
contextWindow: 1000000,
|
||||
reasoning: true,
|
||||
compat: {
|
||||
customFlag: true,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -152,6 +152,40 @@ describe('provider-runtime-sync config delivery', () => {
|
||||
expectNoGatewayLifecycleCalls(gateway);
|
||||
});
|
||||
|
||||
it('writes the default reasoning ladder for a custom provider primary model', async () => {
|
||||
mocks.getProviderConfig.mockReturnValue(undefined);
|
||||
const provider = createProvider({
|
||||
id: 'custom-a1b2c3d4',
|
||||
type: 'custom',
|
||||
baseUrl: 'https://example.com/v1',
|
||||
apiProtocol: 'openai-responses',
|
||||
model: 'gpt-5.5',
|
||||
});
|
||||
|
||||
await syncSavedProviderToRuntime(provider, 'sk-test');
|
||||
|
||||
expect(mocks.syncProviderConfigToOpenClaw).toHaveBeenCalledWith(
|
||||
'custom-a1b2c3d4',
|
||||
'gpt-5.5',
|
||||
expect.objectContaining({
|
||||
baseUrl: 'https://example.com/v1',
|
||||
api: 'openai-responses',
|
||||
}),
|
||||
);
|
||||
expect(mocks.updateAgentModelProvider).toHaveBeenCalledWith(
|
||||
'custom-a1b2c3d4',
|
||||
expect.objectContaining({
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
id: 'gpt-5.5',
|
||||
reasoning: true,
|
||||
compat: { supportedReasoningEfforts: ['low', 'medium', 'high'] },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('propagates per-agent model registry sync failures after saving provider config', async () => {
|
||||
mocks.listAgentsSnapshot.mockRejectedValueOnce(new Error('models.json sync unavailable'));
|
||||
|
||||
|
||||
@@ -176,7 +176,12 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)',
|
||||
baseUrl: 'http://127.0.0.1:3100/v1',
|
||||
api: 'openai-completions',
|
||||
models: [
|
||||
{ id: 'gpt-5.4', name: 'gpt-5.4' },
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
name: 'gpt-5.4',
|
||||
reasoning: true,
|
||||
compat: { supportedReasoningEfforts: ['low', 'high'] },
|
||||
},
|
||||
{ id: 'claude-sonnet-4', name: 'claude-sonnet-4' },
|
||||
{ id: 'gpt-5.4', name: 'duplicate' },
|
||||
],
|
||||
@@ -190,10 +195,14 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)',
|
||||
expect(mocks.saveProviderAccount).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'custom-model-hub',
|
||||
model: 'custom-model-hub/gpt-5.4',
|
||||
reasoningEnabled: true,
|
||||
reasoningEfforts: ['low', 'high'],
|
||||
metadata: { customModels: ['gpt-5.4', 'claude-sonnet-4'] },
|
||||
}));
|
||||
expect(result[0]).toEqual(expect.objectContaining({
|
||||
id: 'custom-model-hub',
|
||||
reasoningEnabled: true,
|
||||
reasoningEfforts: ['low', 'high'],
|
||||
metadata: { customModels: ['gpt-5.4', 'claude-sonnet-4'] },
|
||||
}));
|
||||
});
|
||||
@@ -232,7 +241,12 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)',
|
||||
baseUrl: 'http://127.0.0.1:3100/v1',
|
||||
api: 'openai-completions',
|
||||
models: [
|
||||
{ id: 'claude-sonnet-4', name: 'claude-sonnet-4' },
|
||||
{
|
||||
id: 'claude-sonnet-4',
|
||||
name: 'claude-sonnet-4',
|
||||
reasoning: true,
|
||||
compat: { supportedReasoningEfforts: ['medium', 'high'] },
|
||||
},
|
||||
{ id: 'gpt-5.4', name: 'gpt-5.4' },
|
||||
],
|
||||
},
|
||||
@@ -245,11 +259,15 @@ describe('ProviderService.listAccounts (openclaw.json as sole source of truth)',
|
||||
expect(mocks.saveProviderAccount).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 'custom-model-hub',
|
||||
model: 'custom-model-hub/claude-sonnet-4',
|
||||
reasoningEnabled: true,
|
||||
reasoningEfforts: ['medium', 'high'],
|
||||
metadata: { customModels: ['claude-sonnet-4', 'gpt-5.4'] },
|
||||
}));
|
||||
expect(result[0]).toEqual(expect.objectContaining({
|
||||
id: 'custom-model-hub',
|
||||
model: 'custom-model-hub/claude-sonnet-4',
|
||||
reasoningEnabled: true,
|
||||
reasoningEfforts: ['medium', 'high'],
|
||||
metadata: { customModels: ['claude-sonnet-4', 'gpt-5.4'] },
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user